mirror of
https://github.com/jesseduffield/lazygit.git
synced 2025-05-11 04:15:48 +02:00
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
This commit is contained in:
commit
4a7cf6040e
9 changed files with 174 additions and 33 deletions
|
@ -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
|
||||
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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" },
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: "",
|
||||
|
|
|
@ -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}
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue