Skip to content

Commit 4a7cf60

Browse files
authored
Show the number of lines changed per file in working file tree view (#4015)
- **PR Description** Implements Issue #3643 Adds the number of line changes to the end of each file line in the Files view. Also adds the possibility for the user to enable and disable this feature through the UserConfig. <img width="323" alt="screenshot" src="https://github.com/user-attachments/assets/6f818dd4-fbf5-49f2-b338-1b1fcc73f73a"> - **Please check if the PR fulfills these requirements** * [x] Cheatsheets are up-to-date (run `go generate ./...`) * [x] Code has been formatted (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#code-formatting)) * [x] Tests have been added/updated (see [here](https://github.com/jesseduffield/lazygit/blob/master/pkg/integration/README.md) for the integration test guide) * [ ] Text is internationalised (see [here](https://github.com/jesseduffield/lazygit/blob/master/CONTRIBUTING.md#internationalisation)) * [x] If a new UserConfig entry was added, make sure it can be hot-reloaded (see [here](https://github.com/jesseduffield/lazygit/blob/master/docs/dev/Codebase_Guide.md#using-userconfig)) * [ ] Docs have been updated if necessary * [x] You've read through your own file changes for silly mistakes etc
2 parents f3a5c18 + f455f99 commit 4a7cf60

File tree

9 files changed

+174
-33
lines changed

9 files changed

+174
-33
lines changed

Diff for: docs/Config.md

+3
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,9 @@ gui:
164164
# This can be toggled from within Lazygit with the '~' key, but that will not change the default.
165165
showFileTree: true
166166

167+
# If true, show the number of lines changed per file in the Files view
168+
showNumstatInFilesView: false
169+
167170
# If true, show a random tip in the command log when Lazygit starts
168171
showRandomTip: true
169172

Diff for: pkg/commands/git_commands/file_loader.go

+63
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package git_commands
33
import (
44
"fmt"
55
"path/filepath"
6+
"strconv"
67
"strings"
78

89
"github.com/jesseduffield/lazygit/pkg/commands/models"
@@ -48,6 +49,14 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
4849
}
4950
files := []*models.File{}
5051

52+
fileDiffs := map[string]FileDiff{}
53+
if self.GitCommon.Common.UserConfig().Gui.ShowNumstatInFilesView {
54+
fileDiffs, err = self.getFileDiffs()
55+
if err != nil {
56+
self.Log.Error(err)
57+
}
58+
}
59+
5160
for _, status := range statuses {
5261
if strings.HasPrefix(status.StatusString, "warning") {
5362
self.Log.Warningf("warning when calling git status: %s", status.StatusString)
@@ -60,6 +69,11 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
6069
DisplayString: status.StatusString,
6170
}
6271

72+
if diff, ok := fileDiffs[status.Name]; ok {
73+
file.LinesAdded = diff.LinesAdded
74+
file.LinesDeleted = diff.LinesDeleted
75+
}
76+
6377
models.SetStatusFields(file, status.Change)
6478
files = append(files, file)
6579
}
@@ -87,6 +101,45 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
87101
return files
88102
}
89103

104+
type FileDiff struct {
105+
LinesAdded int
106+
LinesDeleted int
107+
}
108+
109+
func (fileLoader *FileLoader) getFileDiffs() (map[string]FileDiff, error) {
110+
diffs, err := fileLoader.gitDiffNumStat()
111+
if err != nil {
112+
return nil, err
113+
}
114+
115+
splitLines := strings.Split(diffs, "\x00")
116+
117+
fileDiffs := map[string]FileDiff{}
118+
for _, line := range splitLines {
119+
splitLine := strings.Split(line, "\t")
120+
if len(splitLine) != 3 {
121+
continue
122+
}
123+
124+
linesAdded, err := strconv.Atoi(splitLine[0])
125+
if err != nil {
126+
continue
127+
}
128+
linesDeleted, err := strconv.Atoi(splitLine[1])
129+
if err != nil {
130+
continue
131+
}
132+
133+
fileName := splitLine[2]
134+
fileDiffs[fileName] = FileDiff{
135+
LinesAdded: linesAdded,
136+
LinesDeleted: linesDeleted,
137+
}
138+
}
139+
140+
return fileDiffs, nil
141+
}
142+
90143
// GitStatus returns the file status of the repo
91144
type GitStatusOptions struct {
92145
NoRenames bool
@@ -100,6 +153,16 @@ type FileStatus struct {
100153
PreviousName string
101154
}
102155

156+
func (fileLoader *FileLoader) gitDiffNumStat() (string, error) {
157+
return fileLoader.cmd.New(
158+
NewGitCmd("diff").
159+
Arg("--numstat").
160+
Arg("-z").
161+
Arg("HEAD").
162+
ToArgv(),
163+
).DontLog().RunWithOutput()
164+
}
165+
103166
func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) {
104167
cmdArgs := NewGitCmd("status").
105168
Arg(opts.UntrackedFilesArg).

Diff for: pkg/commands/git_commands/file_loader_test.go

+47-25
Original file line numberDiff line numberDiff line change
@@ -11,29 +11,35 @@ import (
1111

1212
func TestFileGetStatusFiles(t *testing.T) {
1313
type scenario struct {
14-
testName string
15-
similarityThreshold int
16-
runner oscommands.ICmdObjRunner
17-
expectedFiles []*models.File
14+
testName string
15+
similarityThreshold int
16+
runner oscommands.ICmdObjRunner
17+
showNumstatInFilesView bool
18+
expectedFiles []*models.File
1819
}
1920

2021
scenarios := []scenario{
2122
{
22-
"No files found",
23-
50,
24-
oscommands.NewFakeRunner(t).
23+
testName: "No files found",
24+
similarityThreshold: 50,
25+
runner: oscommands.NewFakeRunner(t).
2526
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil),
26-
[]*models.File{},
27+
expectedFiles: []*models.File{},
2728
},
2829
{
29-
"Several files found",
30-
50,
31-
oscommands.NewFakeRunner(t).
30+
testName: "Several files found",
31+
similarityThreshold: 50,
32+
runner: oscommands.NewFakeRunner(t).
3233
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
3334
"MM file1.txt\x00A file3.txt\x00AM file2.txt\x00?? file4.txt\x00UU file5.txt",
3435
nil,
36+
).
37+
ExpectGitArgs([]string{"diff", "--numstat", "-z", "HEAD"},
38+
"4\t1\tfile1.txt\x001\t0\tfile2.txt\x002\t2\tfile3.txt\x000\t2\tfile4.txt\x002\t2\tfile5.txt",
39+
nil,
3540
),
36-
[]*models.File{
41+
showNumstatInFilesView: true,
42+
expectedFiles: []*models.File{
3743
{
3844
Name: "file1.txt",
3945
HasStagedChanges: true,
@@ -45,6 +51,8 @@ func TestFileGetStatusFiles(t *testing.T) {
4551
HasInlineMergeConflicts: false,
4652
DisplayString: "MM file1.txt",
4753
ShortStatus: "MM",
54+
LinesAdded: 4,
55+
LinesDeleted: 1,
4856
},
4957
{
5058
Name: "file3.txt",
@@ -57,6 +65,8 @@ func TestFileGetStatusFiles(t *testing.T) {
5765
HasInlineMergeConflicts: false,
5866
DisplayString: "A file3.txt",
5967
ShortStatus: "A ",
68+
LinesAdded: 2,
69+
LinesDeleted: 2,
6070
},
6171
{
6272
Name: "file2.txt",
@@ -69,6 +79,8 @@ func TestFileGetStatusFiles(t *testing.T) {
6979
HasInlineMergeConflicts: false,
7080
DisplayString: "AM file2.txt",
7181
ShortStatus: "AM",
82+
LinesAdded: 1,
83+
LinesDeleted: 0,
7284
},
7385
{
7486
Name: "file4.txt",
@@ -81,6 +93,8 @@ func TestFileGetStatusFiles(t *testing.T) {
8193
HasInlineMergeConflicts: false,
8294
DisplayString: "?? file4.txt",
8395
ShortStatus: "??",
96+
LinesAdded: 0,
97+
LinesDeleted: 2,
8498
},
8599
{
86100
Name: "file5.txt",
@@ -93,15 +107,17 @@ func TestFileGetStatusFiles(t *testing.T) {
93107
HasInlineMergeConflicts: true,
94108
DisplayString: "UU file5.txt",
95109
ShortStatus: "UU",
110+
LinesAdded: 2,
111+
LinesDeleted: 2,
96112
},
97113
},
98114
},
99115
{
100-
"File with new line char",
101-
50,
102-
oscommands.NewFakeRunner(t).
116+
testName: "File with new line char",
117+
similarityThreshold: 50,
118+
runner: oscommands.NewFakeRunner(t).
103119
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "MM a\nb.txt", nil),
104-
[]*models.File{
120+
expectedFiles: []*models.File{
105121
{
106122
Name: "a\nb.txt",
107123
HasStagedChanges: true,
@@ -117,14 +133,14 @@ func TestFileGetStatusFiles(t *testing.T) {
117133
},
118134
},
119135
{
120-
"Renamed files",
121-
50,
122-
oscommands.NewFakeRunner(t).
136+
testName: "Renamed files",
137+
similarityThreshold: 50,
138+
runner: oscommands.NewFakeRunner(t).
123139
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
124140
"R after1.txt\x00before1.txt\x00RM after2.txt\x00before2.txt",
125141
nil,
126142
),
127-
[]*models.File{
143+
expectedFiles: []*models.File{
128144
{
129145
Name: "after1.txt",
130146
PreviousName: "before1.txt",
@@ -154,14 +170,14 @@ func TestFileGetStatusFiles(t *testing.T) {
154170
},
155171
},
156172
{
157-
"File with arrow in name",
158-
50,
159-
oscommands.NewFakeRunner(t).
173+
testName: "File with arrow in name",
174+
similarityThreshold: 50,
175+
runner: oscommands.NewFakeRunner(t).
160176
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
161177
`?? a -> b.txt`,
162178
nil,
163179
),
164-
[]*models.File{
180+
expectedFiles: []*models.File{
165181
{
166182
Name: "a -> b.txt",
167183
HasStagedChanges: false,
@@ -185,8 +201,14 @@ func TestFileGetStatusFiles(t *testing.T) {
185201
appState := &config.AppState{}
186202
appState.RenameSimilarityThreshold = s.similarityThreshold
187203

204+
userConfig := &config.UserConfig{
205+
Gui: config.GuiConfig{
206+
ShowNumstatInFilesView: s.showNumstatInFilesView,
207+
},
208+
}
209+
188210
loader := &FileLoader{
189-
GitCommon: buildGitCommon(commonDeps{appState: appState}),
211+
GitCommon: buildGitCommon(commonDeps{appState: appState, userConfig: userConfig}),
190212
cmd: cmd,
191213
config: &FakeFileLoaderConfig{showUntrackedFiles: "yes"},
192214
getFileType: func(string) string { return "file" },

Diff for: pkg/commands/models/file.go

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ type File struct {
1919
HasInlineMergeConflicts bool
2020
DisplayString string
2121
ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
22+
LinesDeleted int
23+
LinesAdded int
2224

2325
// If true, this must be a worktree folder
2426
IsWorktree bool

Diff for: pkg/config/user_config.go

+3
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ type GuiConfig struct {
109109
// If true, display the files in the file views as a tree. If false, display the files as a flat list.
110110
// This can be toggled from within Lazygit with the '~' key, but that will not change the default.
111111
ShowFileTree bool `yaml:"showFileTree"`
112+
// If true, show the number of lines changed per file in the Files view
113+
ShowNumstatInFilesView bool `yaml:"showNumstatInFilesView"`
112114
// If true, show a random tip in the command log when Lazygit starts
113115
ShowRandomTip bool `yaml:"showRandomTip"`
114116
// If true, show the command log
@@ -714,6 +716,7 @@ func GetDefaultConfig() *UserConfig {
714716
ShowBottomLine: true,
715717
ShowPanelJumps: true,
716718
ShowFileTree: true,
719+
ShowNumstatInFilesView: false,
717720
ShowRandomTip: true,
718721
ShowIcons: false,
719722
NerdFontsVersion: "",

Diff for: pkg/gui/context/working_tree_context.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
3030

3131
getDisplayStrings := func(_ int, _ int) [][]string {
3232
showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons
33-
lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons)
33+
showNumstat := c.UserConfig().Gui.ShowNumstatInFilesView
34+
lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat)
3435
return lo.Map(lines, func(line string, _ int) []string {
3536
return []string{line}
3637
})

Diff for: pkg/gui/presentation/files.go

+26-1
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,13 @@ func RenderFileTree(
2222
tree filetree.IFileTree,
2323
submoduleConfigs []*models.SubmoduleConfig,
2424
showFileIcons bool,
25+
showNumstat bool,
2526
) []string {
2627
collapsedPaths := tree.CollapsedPaths()
2728
return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.File], treeDepth int, visualDepth int, isCollapsed bool) string {
2829
fileNode := filetree.NewFileNode(node)
2930

30-
return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showFileIcons, submoduleConfigs, node)
31+
return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node)
3132
})
3233
}
3334

@@ -111,6 +112,7 @@ func getFileLine(
111112
hasStagedChanges bool,
112113
treeDepth int,
113114
visualDepth int,
115+
showNumstat,
114116
showFileIcons bool,
115117
submoduleConfigs []*models.SubmoduleConfig,
116118
node *filetree.Node[models.File],
@@ -165,6 +167,12 @@ func getFileLine(
165167
output += theme.DefaultTextColor.Sprint(" (submodule)")
166168
}
167169

170+
if file != nil && showNumstat {
171+
if lineChanges := formatLineChanges(file.LinesAdded, file.LinesDeleted); lineChanges != "" {
172+
output += " " + lineChanges
173+
}
174+
}
175+
168176
return output
169177
}
170178

@@ -186,6 +194,23 @@ func formatFileStatus(file *models.File, restColor style.TextStyle) string {
186194
return firstCharCl.Sprint(firstChar) + secondCharCl.Sprint(secondChar)
187195
}
188196

197+
func formatLineChanges(linesAdded, linesDeleted int) string {
198+
output := ""
199+
200+
if linesAdded != 0 {
201+
output += style.FgGreen.Sprintf("+%d", linesAdded)
202+
}
203+
204+
if linesDeleted != 0 {
205+
if output != "" {
206+
output += " "
207+
}
208+
output += style.FgRed.Sprintf("-%d", linesDeleted)
209+
}
210+
211+
return output
212+
}
213+
189214
func getCommitFileLine(
190215
isCollapsed bool,
191216
treeDepth int,

0 commit comments

Comments
 (0)