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.
This commit is contained in:
johannaschwarz 2024-12-08 12:04:45 +01:00 committed by Stefan Haller
parent f3a5c184e1
commit f455f99705
9 changed files with 174 additions and 33 deletions

View file

@ -164,6 +164,9 @@ gui:
# This can be toggled from within Lazygit with the '~' key, but that will not change the default. # This can be toggled from within Lazygit with the '~' key, but that will not change the default.
showFileTree: true 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 # If true, show a random tip in the command log when Lazygit starts
showRandomTip: true showRandomTip: true

View file

@ -3,6 +3,7 @@ package git_commands
import ( import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/commands/models"
@ -48,6 +49,14 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
} }
files := []*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 { for _, status := range statuses {
if strings.HasPrefix(status.StatusString, "warning") { if strings.HasPrefix(status.StatusString, "warning") {
self.Log.Warningf("warning when calling git status: %s", status.StatusString) 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, DisplayString: status.StatusString,
} }
if diff, ok := fileDiffs[status.Name]; ok {
file.LinesAdded = diff.LinesAdded
file.LinesDeleted = diff.LinesDeleted
}
models.SetStatusFields(file, status.Change) models.SetStatusFields(file, status.Change)
files = append(files, file) files = append(files, file)
} }
@ -87,6 +101,45 @@ func (self *FileLoader) GetStatusFiles(opts GetStatusFileOptions) []*models.File
return files 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 // GitStatus returns the file status of the repo
type GitStatusOptions struct { type GitStatusOptions struct {
NoRenames bool NoRenames bool
@ -100,6 +153,16 @@ type FileStatus struct {
PreviousName string 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) { func (self *FileLoader) gitStatus(opts GitStatusOptions) ([]FileStatus, error) {
cmdArgs := NewGitCmd("status"). cmdArgs := NewGitCmd("status").
Arg(opts.UntrackedFilesArg). Arg(opts.UntrackedFilesArg).

View file

@ -11,29 +11,35 @@ import (
func TestFileGetStatusFiles(t *testing.T) { func TestFileGetStatusFiles(t *testing.T) {
type scenario struct { type scenario struct {
testName string testName string
similarityThreshold int similarityThreshold int
runner oscommands.ICmdObjRunner runner oscommands.ICmdObjRunner
expectedFiles []*models.File showNumstatInFilesView bool
expectedFiles []*models.File
} }
scenarios := []scenario{ scenarios := []scenario{
{ {
"No files found", testName: "No files found",
50, similarityThreshold: 50,
oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil), ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "", nil),
[]*models.File{}, expectedFiles: []*models.File{},
}, },
{ {
"Several files found", testName: "Several files found",
50, similarityThreshold: 50,
oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, 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", "MM file1.txt\x00A file3.txt\x00AM file2.txt\x00?? file4.txt\x00UU file5.txt",
nil, 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", Name: "file1.txt",
HasStagedChanges: true, HasStagedChanges: true,
@ -45,6 +51,8 @@ func TestFileGetStatusFiles(t *testing.T) {
HasInlineMergeConflicts: false, HasInlineMergeConflicts: false,
DisplayString: "MM file1.txt", DisplayString: "MM file1.txt",
ShortStatus: "MM", ShortStatus: "MM",
LinesAdded: 4,
LinesDeleted: 1,
}, },
{ {
Name: "file3.txt", Name: "file3.txt",
@ -57,6 +65,8 @@ func TestFileGetStatusFiles(t *testing.T) {
HasInlineMergeConflicts: false, HasInlineMergeConflicts: false,
DisplayString: "A file3.txt", DisplayString: "A file3.txt",
ShortStatus: "A ", ShortStatus: "A ",
LinesAdded: 2,
LinesDeleted: 2,
}, },
{ {
Name: "file2.txt", Name: "file2.txt",
@ -69,6 +79,8 @@ func TestFileGetStatusFiles(t *testing.T) {
HasInlineMergeConflicts: false, HasInlineMergeConflicts: false,
DisplayString: "AM file2.txt", DisplayString: "AM file2.txt",
ShortStatus: "AM", ShortStatus: "AM",
LinesAdded: 1,
LinesDeleted: 0,
}, },
{ {
Name: "file4.txt", Name: "file4.txt",
@ -81,6 +93,8 @@ func TestFileGetStatusFiles(t *testing.T) {
HasInlineMergeConflicts: false, HasInlineMergeConflicts: false,
DisplayString: "?? file4.txt", DisplayString: "?? file4.txt",
ShortStatus: "??", ShortStatus: "??",
LinesAdded: 0,
LinesDeleted: 2,
}, },
{ {
Name: "file5.txt", Name: "file5.txt",
@ -93,15 +107,17 @@ func TestFileGetStatusFiles(t *testing.T) {
HasInlineMergeConflicts: true, HasInlineMergeConflicts: true,
DisplayString: "UU file5.txt", DisplayString: "UU file5.txt",
ShortStatus: "UU", ShortStatus: "UU",
LinesAdded: 2,
LinesDeleted: 2,
}, },
}, },
}, },
{ {
"File with new line char", testName: "File with new line char",
50, similarityThreshold: 50,
oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, "MM a\nb.txt", nil), 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", Name: "a\nb.txt",
HasStagedChanges: true, HasStagedChanges: true,
@ -117,14 +133,14 @@ func TestFileGetStatusFiles(t *testing.T) {
}, },
}, },
{ {
"Renamed files", testName: "Renamed files",
50, similarityThreshold: 50,
oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
"R after1.txt\x00before1.txt\x00RM after2.txt\x00before2.txt", "R after1.txt\x00before1.txt\x00RM after2.txt\x00before2.txt",
nil, nil,
), ),
[]*models.File{ expectedFiles: []*models.File{
{ {
Name: "after1.txt", Name: "after1.txt",
PreviousName: "before1.txt", PreviousName: "before1.txt",
@ -154,14 +170,14 @@ func TestFileGetStatusFiles(t *testing.T) {
}, },
}, },
{ {
"File with arrow in name", testName: "File with arrow in name",
50, similarityThreshold: 50,
oscommands.NewFakeRunner(t). runner: oscommands.NewFakeRunner(t).
ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"}, ExpectGitArgs([]string{"status", "--untracked-files=yes", "--porcelain", "-z", "--find-renames=50%"},
`?? a -> b.txt`, `?? a -> b.txt`,
nil, nil,
), ),
[]*models.File{ expectedFiles: []*models.File{
{ {
Name: "a -> b.txt", Name: "a -> b.txt",
HasStagedChanges: false, HasStagedChanges: false,
@ -185,8 +201,14 @@ func TestFileGetStatusFiles(t *testing.T) {
appState := &config.AppState{} appState := &config.AppState{}
appState.RenameSimilarityThreshold = s.similarityThreshold appState.RenameSimilarityThreshold = s.similarityThreshold
userConfig := &config.UserConfig{
Gui: config.GuiConfig{
ShowNumstatInFilesView: s.showNumstatInFilesView,
},
}
loader := &FileLoader{ loader := &FileLoader{
GitCommon: buildGitCommon(commonDeps{appState: appState}), GitCommon: buildGitCommon(commonDeps{appState: appState, userConfig: userConfig}),
cmd: cmd, cmd: cmd,
config: &FakeFileLoaderConfig{showUntrackedFiles: "yes"}, config: &FakeFileLoaderConfig{showUntrackedFiles: "yes"},
getFileType: func(string) string { return "file" }, getFileType: func(string) string { return "file" },

View file

@ -19,6 +19,8 @@ type File struct {
HasInlineMergeConflicts bool HasInlineMergeConflicts bool
DisplayString string DisplayString string
ShortStatus string // e.g. 'AD', ' A', 'M ', '??' ShortStatus string // e.g. 'AD', ' A', 'M ', '??'
LinesDeleted int
LinesAdded int
// If true, this must be a worktree folder // If true, this must be a worktree folder
IsWorktree bool IsWorktree bool

View file

@ -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. // 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. // This can be toggled from within Lazygit with the '~' key, but that will not change the default.
ShowFileTree bool `yaml:"showFileTree"` 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 // If true, show a random tip in the command log when Lazygit starts
ShowRandomTip bool `yaml:"showRandomTip"` ShowRandomTip bool `yaml:"showRandomTip"`
// If true, show the command log // If true, show the command log
@ -714,6 +716,7 @@ func GetDefaultConfig() *UserConfig {
ShowBottomLine: true, ShowBottomLine: true,
ShowPanelJumps: true, ShowPanelJumps: true,
ShowFileTree: true, ShowFileTree: true,
ShowNumstatInFilesView: false,
ShowRandomTip: true, ShowRandomTip: true,
ShowIcons: false, ShowIcons: false,
NerdFontsVersion: "", NerdFontsVersion: "",

View file

@ -30,7 +30,8 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
getDisplayStrings := func(_ int, _ int) [][]string { getDisplayStrings := func(_ int, _ int) [][]string {
showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons 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 lo.Map(lines, func(line string, _ int) []string {
return []string{line} return []string{line}
}) })

View file

@ -22,12 +22,13 @@ func RenderFileTree(
tree filetree.IFileTree, tree filetree.IFileTree,
submoduleConfigs []*models.SubmoduleConfig, submoduleConfigs []*models.SubmoduleConfig,
showFileIcons bool, showFileIcons bool,
showNumstat bool,
) []string { ) []string {
collapsedPaths := tree.CollapsedPaths() collapsedPaths := tree.CollapsedPaths()
return renderAux(tree.GetRoot().Raw(), collapsedPaths, -1, -1, func(node *filetree.Node[models.File], treeDepth int, visualDepth int, isCollapsed bool) string { 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) 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, hasStagedChanges bool,
treeDepth int, treeDepth int,
visualDepth int, visualDepth int,
showNumstat,
showFileIcons bool, showFileIcons bool,
submoduleConfigs []*models.SubmoduleConfig, submoduleConfigs []*models.SubmoduleConfig,
node *filetree.Node[models.File], node *filetree.Node[models.File],
@ -165,6 +167,12 @@ func getFileLine(
output += theme.DefaultTextColor.Sprint(" (submodule)") output += theme.DefaultTextColor.Sprint(" (submodule)")
} }
if file != nil && showNumstat {
if lineChanges := formatLineChanges(file.LinesAdded, file.LinesDeleted); lineChanges != "" {
output += " " + lineChanges
}
}
return output return output
} }
@ -186,6 +194,23 @@ func formatFileStatus(file *models.File, restColor style.TextStyle) string {
return firstCharCl.Sprint(firstChar) + secondCharCl.Sprint(secondChar) 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( func getCommitFileLine(
isCollapsed bool, isCollapsed bool,
treeDepth int, treeDepth int,

View file

@ -19,11 +19,12 @@ func toStringSlice(str string) []string {
func TestRenderFileTree(t *testing.T) { func TestRenderFileTree(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
root *filetree.FileNode root *filetree.FileNode
files []*models.File files []*models.File
collapsedPaths []string collapsedPaths []string
expected []string showLineChanges bool
expected []string
}{ }{
{ {
name: "nil node", name: "nil node",
@ -37,6 +38,22 @@ func TestRenderFileTree(t *testing.T) {
}, },
expected: []string{" M test"}, 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", name: "big example",
files: []*models.File{ files: []*models.File{
@ -72,7 +89,7 @@ M file1
for _, path := range s.collapsedPaths { for _, path := range s.collapsedPaths {
viewModel.ToggleCollapsed(path) viewModel.ToggleCollapsed(path)
} }
result := RenderFileTree(viewModel, nil, false) result := RenderFileTree(viewModel, nil, false, s.showLineChanges)
assert.EqualValues(t, s.expected, result) assert.EqualValues(t, s.expected, result)
}) })
} }

View file

@ -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.", "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 "default": true
}, },
"showNumstatInFilesView": {
"type": "boolean",
"description": "If true, show the number of lines changed per file in the Files view",
"default": false
},
"showRandomTip": { "showRandomTip": {
"type": "boolean", "type": "boolean",
"description": "If true, show a random tip in the command log when Lazygit starts", "description": "If true, show a random tip in the command log when Lazygit starts",