Add user config gui.addRootItemInFileTree

This commit is contained in:
Stefan Haller
2025-05-24 18:17:25 +02:00
parent ffb8586795
commit 3cff48437e
12 changed files with 388 additions and 36 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext {
getDisplayStrings := func(_ int, _ int) [][]string {
showFileIcons := icons.IsIconEnabled() && c.UserConfig().Gui.ShowFileIcons
showNumstat := c.UserConfig().Gui.ShowNumstatInFilesView
lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons)
lines := presentation.RenderFileTree(viewModel, c.Model().Submodules, showFileIcons, showNumstat, &c.UserConfig().Gui.CustomIcons, c.UserConfig().Gui.ShowRootItemInFileTree)
return lo.Map(lines, func(line string, _ int) []string {
return []string{line}
})
+16 -8
View File
@@ -7,14 +7,14 @@ import (
"github.com/jesseduffield/lazygit/pkg/commands/models"
)
func BuildTreeFromFiles(files []*models.File) *Node[models.File] {
func BuildTreeFromFiles(files []*models.File, showRootItem bool) *Node[models.File] {
root := &Node[models.File]{}
childrenMapsByNode := make(map[*Node[models.File]]map[string]*Node[models.File])
var curr *Node[models.File]
for _, file := range files {
splitPath := split("./" + file.Path)
splitPath := SplitFileTreePath(file.Path, showRootItem)
curr = root
outer:
for i := range splitPath {
@@ -63,19 +63,19 @@ func BuildTreeFromFiles(files []*models.File) *Node[models.File] {
return root
}
func BuildFlatTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] {
rootAux := BuildTreeFromCommitFiles(files)
func BuildFlatTreeFromCommitFiles(files []*models.CommitFile, showRootItem bool) *Node[models.CommitFile] {
rootAux := BuildTreeFromCommitFiles(files, showRootItem)
sortedFiles := rootAux.GetLeaves()
return &Node[models.CommitFile]{Children: sortedFiles}
}
func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFile] {
func BuildTreeFromCommitFiles(files []*models.CommitFile, showRootItem bool) *Node[models.CommitFile] {
root := &Node[models.CommitFile]{}
var curr *Node[models.CommitFile]
for _, file := range files {
splitPath := split("./" + file.Path)
splitPath := SplitFileTreePath(file.Path, showRootItem)
curr = root
outer:
for i := range splitPath {
@@ -115,8 +115,8 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile) *Node[models.CommitFil
return root
}
func BuildFlatTreeFromFiles(files []*models.File) *Node[models.File] {
rootAux := BuildTreeFromFiles(files)
func BuildFlatTreeFromFiles(files []*models.File, showRootItem bool) *Node[models.File] {
rootAux := BuildTreeFromFiles(files, showRootItem)
sortedFiles := rootAux.GetLeaves()
// from top down we have merge conflict files, then tracked file, then untracked
@@ -160,3 +160,11 @@ func split(str string) []string {
func join(strs []string) string {
return strings.Join(strs, "/")
}
func SplitFileTreePath(path string, showRootItem bool) []string {
if showRootItem {
return split("./" + path)
}
return split(path)
}
+251 -16
View File
@@ -9,9 +9,10 @@ import (
func TestBuildTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *Node[models.File]
name string
files []*models.File
showRootItem bool
expected *Node[models.File]
}{
{
name: "no files",
@@ -31,6 +32,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -51,6 +53,37 @@ func TestBuildTreeFromFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.File{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
path: "dir1",
CompressionLevel: 0,
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/a"},
path: "dir1/a",
},
{
File: &models.File{Path: "dir1/b"},
path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
@@ -61,6 +94,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "dir2/dir4/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -92,6 +126,43 @@ func TestBuildTreeFromFiles(t *testing.T) {
},
},
},
{
name: "paths that can be compressed, no root item",
files: []*models.File{
{
Path: "dir1/dir3/a",
},
{
Path: "dir2/dir4/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
path: "dir1/dir3",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/dir3/a"},
path: "dir1/dir3/a",
},
},
CompressionLevel: 1,
},
{
path: "dir2/dir4",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir2/dir4/b"},
path: "dir2/dir4/b",
},
},
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
@@ -102,6 +173,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -135,6 +207,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -164,7 +237,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromFiles(s.files)
result := BuildTreeFromFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -172,9 +245,10 @@ func TestBuildTreeFromFiles(t *testing.T) {
func TestBuildFlatTreeFromFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.File
expected *Node[models.File]
name string
files []*models.File
showRootItem bool
expected *Node[models.File]
}{
{
name: "no files",
@@ -194,6 +268,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -210,6 +285,33 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.File{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/a"},
path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.File{Path: "dir1/b"},
path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.File{
@@ -220,6 +322,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Path: "dir2/b",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -236,6 +339,33 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
},
},
},
{
name: "paths that can be compressed, no root item",
files: []*models.File{
{
Path: "dir1/a",
},
{
Path: "dir2/b",
},
},
showRootItem: false,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
{
File: &models.File{Path: "dir1/a"},
path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.File{Path: "dir2/b"},
path: "dir2/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.File{
@@ -246,6 +376,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -288,6 +419,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
Tracked: true,
},
},
showRootItem: true,
expected: &Node[models.File]{
path: "",
Children: []*Node[models.File]{
@@ -322,7 +454,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromFiles(s.files)
result := BuildFlatTreeFromFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -330,9 +462,10 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
func TestBuildTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *Node[models.CommitFile]
name string
files []*models.CommitFile
showRootItem bool
expected *Node[models.CommitFile]
}{
{
name: "no files",
@@ -352,6 +485,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -372,6 +506,37 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.CommitFile{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
{
path: "dir1",
CompressionLevel: 0,
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir1/a"},
path: "dir1/a",
},
{
File: &models.CommitFile{Path: "dir1/b"},
path: "dir1/b",
},
},
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
@@ -382,6 +547,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Path: "dir2/dir4/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -413,6 +579,43 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
},
},
},
{
name: "paths that can be compressed, no root item",
files: []*models.CommitFile{
{
Path: "dir1/dir3/a",
},
{
Path: "dir2/dir4/b",
},
},
showRootItem: false,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
{
path: "dir1/dir3",
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir1/dir3/a"},
path: "dir1/dir3/a",
},
},
CompressionLevel: 1,
},
{
path: "dir2/dir4",
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir2/dir4/b"},
path: "dir2/dir4/b",
},
},
CompressionLevel: 1,
},
},
},
},
{
name: "paths that can be sorted",
files: []*models.CommitFile{
@@ -423,6 +626,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -446,7 +650,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildTreeFromCommitFiles(s.files)
result := BuildTreeFromCommitFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -454,9 +658,10 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
scenarios := []struct {
name string
files []*models.CommitFile
expected *Node[models.CommitFile]
name string
files []*models.CommitFile
showRootItem bool
expected *Node[models.CommitFile]
}{
{
name: "no files",
@@ -476,6 +681,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Path: "dir1/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -492,6 +698,33 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
},
},
},
{
name: "files in same directory, not root item",
files: []*models.CommitFile{
{
Path: "dir1/a",
},
{
Path: "dir1/b",
},
},
showRootItem: false,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
{
File: &models.CommitFile{Path: "dir1/a"},
path: "dir1/a",
CompressionLevel: 0,
},
{
File: &models.CommitFile{Path: "dir1/b"},
path: "dir1/b",
CompressionLevel: 0,
},
},
},
},
{
name: "paths that can be compressed",
files: []*models.CommitFile{
@@ -502,6 +735,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Path: "dir2/b",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -528,6 +762,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
Path: "a",
},
},
showRootItem: true,
expected: &Node[models.CommitFile]{
path: "",
Children: []*Node[models.CommitFile]{
@@ -546,7 +781,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
result := BuildFlatTreeFromCommitFiles(s.files)
result := BuildFlatTreeFromCommitFiles(s.files, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
+3 -2
View File
@@ -94,10 +94,11 @@ func (self *CommitFileTree) GetAllFiles() []*models.CommitFile {
}
func (self *CommitFileTree) SetTree() {
showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree
if self.showTree {
self.tree = BuildTreeFromCommitFiles(self.getFiles())
self.tree = BuildTreeFromCommitFiles(self.getFiles(), showRootItem)
} else {
self.tree = BuildFlatTreeFromCommitFiles(self.getFiles())
self.tree = BuildFlatTreeFromCommitFiles(self.getFiles(), showRootItem)
}
}
+3 -2
View File
@@ -168,10 +168,11 @@ func (self *FileTree) GetAllFiles() []*models.File {
func (self *FileTree) SetTree() {
filesForDisplay := self.getFilesForDisplay()
showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree
if self.showTree {
self.tree = BuildTreeFromFiles(filesForDisplay)
self.tree = BuildTreeFromFiles(filesForDisplay, showRootItem)
} else {
self.tree = BuildFlatTreeFromFiles(filesForDisplay)
self.tree = BuildFlatTreeFromFiles(filesForDisplay, showRootItem)
}
}
+6 -4
View File
@@ -25,12 +25,13 @@ func RenderFileTree(
showFileIcons bool,
showNumstat bool,
customIconsConfig *config.CustomIconsConfig,
showRootItem 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, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig)
return getFileLine(isCollapsed, fileNode.GetHasUnstagedChanges(), fileNode.GetHasStagedChanges(), treeDepth, visualDepth, showNumstat, showFileIcons, submoduleConfigs, node, customIconsConfig, showRootItem)
})
}
@@ -120,8 +121,9 @@ func getFileLine(
submoduleConfigs []*models.SubmoduleConfig,
node *filetree.Node[models.File],
customIconsConfig *config.CustomIconsConfig,
showRootItem bool,
) string {
name := fileNameAtDepth(node, treeDepth)
name := fileNameAtDepth(node, treeDepth, showRootItem)
output := ""
var nameColor style.TextStyle
@@ -297,7 +299,7 @@ func getColorForChangeStatus(changeStatus string) style.TextStyle {
}
}
func fileNameAtDepth(node *filetree.Node[models.File], depth int) string {
func fileNameAtDepth(node *filetree.Node[models.File], depth int, showRootItem bool) string {
splitName := split(node.GetInternalPath())
if depth == 0 && splitName[0] == "." {
if len(splitName) == 1 {
@@ -308,7 +310,7 @@ func fileNameAtDepth(node *filetree.Node[models.File], depth int) string {
name := join(splitName[depth:])
if node.File != nil && node.File.IsRename() {
splitPrevName := split("./" + node.File.PreviousPath)
splitPrevName := filetree.SplitFileTreePath(node.File.PreviousPath, showRootItem)
prevName := node.File.PreviousPath
// if the file has just been renamed inside the same directory, we can shave off
+60 -3
View File
@@ -26,6 +26,7 @@ func TestRenderFileTree(t *testing.T) {
files []*models.File
collapsedPaths []string
showLineChanges bool
showRootItem bool
expected []string
}{
{
@@ -38,7 +39,8 @@ func TestRenderFileTree(t *testing.T) {
files: []*models.File{
{Path: "test", ShortStatus: " M", HasStagedChanges: true},
},
expected: []string{" M test"},
showRootItem: true,
expected: []string{" M test"},
},
{
name: "numstat",
@@ -49,6 +51,7 @@ func TestRenderFileTree(t *testing.T) {
{Path: "test4", ShortStatus: " M", HasStagedChanges: true, LinesAdded: 0, LinesDeleted: 0},
},
showLineChanges: true,
showRootItem: true,
expected: []string{
"▼ /",
" M test +1 -1",
@@ -67,6 +70,7 @@ func TestRenderFileTree(t *testing.T) {
{Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
showRootItem: true,
expected: toStringSlice(
`
▼ /
@@ -81,6 +85,30 @@ func TestRenderFileTree(t *testing.T) {
),
collapsedPaths: []string{"./dir1"},
},
{
name: "big example without root item",
files: []*models.File{
{Path: "dir1/file2", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "dir1/file3", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "dir2/dir2/file3", ShortStatus: " M", HasStagedChanges: true},
{Path: "dir2/dir2/file4", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "dir2/file5", ShortStatus: "M ", HasUnstagedChanges: true},
{Path: "file1", ShortStatus: "M ", HasUnstagedChanges: true},
},
showRootItem: false,
expected: toStringSlice(
`
▶ dir1
▼ dir2
▼ dir2
M file3
M file4
M file5
M file1
`,
),
collapsedPaths: []string{"dir1"},
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
@@ -89,12 +117,13 @@ func TestRenderFileTree(t *testing.T) {
for _, s := range scenarios {
t.Run(s.name, func(t *testing.T) {
common := common.NewDummyCommon()
common.UserConfig().Gui.ShowRootItemInFileTree = s.showRootItem
viewModel := filetree.NewFileTree(func() []*models.File { return s.files }, common, true)
viewModel.SetTree()
for _, path := range s.collapsedPaths {
viewModel.ToggleCollapsed(path)
}
result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{})
result := RenderFileTree(viewModel, nil, false, s.showLineChanges, &config.CustomIconsConfig{}, s.showRootItem)
assert.EqualValues(t, s.expected, result)
})
}
@@ -106,6 +135,7 @@ func TestRenderCommitFileTree(t *testing.T) {
root *filetree.FileNode
files []*models.CommitFile
collapsedPaths []string
showRootItem bool
expected []string
}{
{
@@ -118,7 +148,8 @@ func TestRenderCommitFileTree(t *testing.T) {
files: []*models.CommitFile{
{Path: "test", ChangeStatus: "A"},
},
expected: []string{"A test"},
showRootItem: true,
expected: []string{"A test"},
},
{
name: "big example",
@@ -130,6 +161,7 @@ func TestRenderCommitFileTree(t *testing.T) {
{Path: "dir2/file5", ChangeStatus: "M"},
{Path: "file1", ChangeStatus: "M"},
},
showRootItem: true,
expected: toStringSlice(
`
▼ /
@@ -144,6 +176,30 @@ func TestRenderCommitFileTree(t *testing.T) {
),
collapsedPaths: []string{"./dir1"},
},
{
name: "big example without root item",
files: []*models.CommitFile{
{Path: "dir1/file2", ChangeStatus: "M"},
{Path: "dir1/file3", ChangeStatus: "A"},
{Path: "dir2/dir2/file3", ChangeStatus: "D"},
{Path: "dir2/dir2/file4", ChangeStatus: "M"},
{Path: "dir2/file5", ChangeStatus: "M"},
{Path: "file1", ChangeStatus: "M"},
},
showRootItem: false,
expected: toStringSlice(
`
▶ dir1
▼ dir2
▼ dir2
D file3
M file4
M file5
M file1
`,
),
collapsedPaths: []string{"dir1"},
},
}
oldColorLevel := color.ForceSetColorLevel(terminfo.ColorLevelNone)
@@ -154,6 +210,7 @@ func TestRenderCommitFileTree(t *testing.T) {
hashPool := &utils.StringPool{}
common := common.NewDummyCommon()
common.UserConfig().Gui.ShowRootItemInFileTree = s.showRootItem
viewModel := filetree.NewCommitFileTreeViewModel(func() []*models.CommitFile { return s.files }, common, true)
viewModel.SetRef(models.NewCommit(hashPool, models.NewCommitOpts{Hash: "1234"}))
viewModel.SetTree()