From f455f99705759c91fb50d3e1777fe887d5f4db22 Mon Sep 17 00:00:00 2001
From: johannaschwarz <johanna@schwarzweb.de>
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 d63987f06..e6a4a4a75 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 72329543a..4cf0da2d0 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 5a9f15700..cc4bbaa07 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 45f1ec5d7..4be424e22 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 b02a959f5..dd732e0be 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 88d2ab9fe..cef1eb5c2 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 5941934c6..ed558c170 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 f04199141..a6cdbf99d 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 7b0ef0b2b..1498b82ba 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",