mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-09-10 15:46:26 -04:00
Make file sort order and case sensitivity configurable, and default to mix files and folders (#5427)
Add new config options for the sort order of files vs. directories, and for sorting case sensitively or not. Set the sort order's default from the former foldersFirst to mixed, so this is a change in behavior. I find this useful because it now matches git's order, so if you look at the diff of a commit, the TOC at the top has the same order as the file tree you see when entering the commit. Closes #4070.
This commit is contained in:
commit
050bffa580
|
|
@ -222,6 +222,13 @@ gui:
|
|||
# item at top level.
|
||||
showRootItemInFileTree: true
|
||||
|
||||
# How to sort files and directories in the file tree.
|
||||
# One of: 'mixed' (default) | 'filesFirst' | 'foldersFirst'
|
||||
fileTreeSortOrder: mixed
|
||||
|
||||
# If true (default), sort the file tree case-sensitively.
|
||||
fileTreeSortCaseSensitive: true
|
||||
|
||||
# If true, show the number of lines changed per file in the Files view
|
||||
showNumstatInFilesView: false
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,11 @@ type GuiConfig struct {
|
|||
ShowFileTree bool `yaml:"showFileTree"`
|
||||
// If true, add a "/" root item in the file tree representing the root of the repository. It is only added when necessary, i.e. when there is more than one item at top level.
|
||||
ShowRootItemInFileTree bool `yaml:"showRootItemInFileTree"`
|
||||
// How to sort files and directories in the file tree.
|
||||
// One of: 'mixed' (default) | 'filesFirst' | 'foldersFirst'
|
||||
FileTreeSortOrder string `yaml:"fileTreeSortOrder" jsonschema:"enum=mixed,enum=filesFirst,enum=foldersFirst"`
|
||||
// If true (default), sort the file tree case-sensitively.
|
||||
FileTreeSortCaseSensitive bool `yaml:"fileTreeSortCaseSensitive"`
|
||||
// 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
|
||||
|
|
@ -795,6 +800,8 @@ func GetDefaultConfig() *UserConfig {
|
|||
ShowPanelJumps: true,
|
||||
ShowFileTree: true,
|
||||
ShowRootItemInFileTree: true,
|
||||
FileTreeSortOrder: "mixed",
|
||||
FileTreeSortCaseSensitive: true,
|
||||
ShowNumstatInFilesView: false,
|
||||
ShowRandomTip: true,
|
||||
ShowIcons: false,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ func (config *UserConfig) Validate() error {
|
|||
[]string{"none", "onlyArrow", "arrowAndNumber"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("gui.fileTreeSortOrder", config.Gui.FileTreeSortOrder,
|
||||
[]string{"mixed", "filesFirst", "foldersFirst"}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateEnum("git.autoForwardBranches", config.Git.AutoForwardBranches,
|
||||
[]string{"none", "onlyMainBranches", "allBranches"}); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import (
|
|||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
)
|
||||
|
||||
func BuildTreeFromFiles(files []*models.File, showRootItem bool) *Node[models.File] {
|
||||
func BuildTreeFromFiles(
|
||||
files []*models.File,
|
||||
showRootItem bool,
|
||||
cmp func(a, b *Node[models.File]) int,
|
||||
) *Node[models.File] {
|
||||
root := &Node[models.File]{}
|
||||
|
||||
childrenMapsByNode := make(map[*Node[models.File]]map[string]*Node[models.File])
|
||||
|
|
@ -57,20 +61,28 @@ func BuildTreeFromFiles(files []*models.File, showRootItem bool) *Node[models.Fi
|
|||
}
|
||||
}
|
||||
|
||||
root.Sort()
|
||||
root.Sort(cmp)
|
||||
root.Compress()
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func BuildFlatTreeFromCommitFiles(files []*models.CommitFile, showRootItem bool) *Node[models.CommitFile] {
|
||||
rootAux := BuildTreeFromCommitFiles(files, showRootItem)
|
||||
func BuildFlatTreeFromCommitFiles(
|
||||
files []*models.CommitFile,
|
||||
showRootItem bool,
|
||||
cmp func(a, b *Node[models.CommitFile]) int,
|
||||
) *Node[models.CommitFile] {
|
||||
rootAux := BuildTreeFromCommitFiles(files, showRootItem, cmp)
|
||||
sortedFiles := rootAux.GetLeaves()
|
||||
|
||||
return &Node[models.CommitFile]{Children: sortedFiles}
|
||||
}
|
||||
|
||||
func BuildTreeFromCommitFiles(files []*models.CommitFile, showRootItem bool) *Node[models.CommitFile] {
|
||||
func BuildTreeFromCommitFiles(
|
||||
files []*models.CommitFile,
|
||||
showRootItem bool,
|
||||
cmp func(a, b *Node[models.CommitFile]) int,
|
||||
) *Node[models.CommitFile] {
|
||||
root := &Node[models.CommitFile]{}
|
||||
|
||||
var curr *Node[models.CommitFile]
|
||||
|
|
@ -109,14 +121,18 @@ func BuildTreeFromCommitFiles(files []*models.CommitFile, showRootItem bool) *No
|
|||
}
|
||||
}
|
||||
|
||||
root.Sort()
|
||||
root.Sort(cmp)
|
||||
root.Compress()
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
func BuildFlatTreeFromFiles(files []*models.File, showRootItem bool) *Node[models.File] {
|
||||
rootAux := BuildTreeFromFiles(files, showRootItem)
|
||||
func BuildFlatTreeFromFiles(
|
||||
files []*models.File,
|
||||
showRootItem bool,
|
||||
cmp func(a, b *Node[models.File]) int,
|
||||
) *Node[models.File] {
|
||||
rootAux := BuildTreeFromFiles(files, showRootItem, cmp)
|
||||
sortedFiles := rootAux.GetLeaves()
|
||||
|
||||
// from top down we have merge conflict files, then tracked file, then untracked
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ func TestBuildTreeFromFiles(t *testing.T) {
|
|||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result := BuildTreeFromFiles(s.files, s.showRootItem)
|
||||
result := BuildTreeFromFiles(s.files, s.showRootItem, NodeSortComparator[models.File]("mixed", false))
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
|
|
@ -454,7 +454,7 @@ func TestBuildFlatTreeFromFiles(t *testing.T) {
|
|||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result := BuildFlatTreeFromFiles(s.files, s.showRootItem)
|
||||
result := BuildFlatTreeFromFiles(s.files, s.showRootItem, NodeSortComparator[models.File]("mixed", false))
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
|
|
@ -650,7 +650,7 @@ func TestBuildTreeFromCommitFiles(t *testing.T) {
|
|||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result := BuildTreeFromCommitFiles(s.files, s.showRootItem)
|
||||
result := BuildTreeFromCommitFiles(s.files, s.showRootItem, NodeSortComparator[models.CommitFile]("mixed", false))
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
|
|
@ -781,7 +781,7 @@ func TestBuildFlatTreeFromCommitFiles(t *testing.T) {
|
|||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
result := BuildFlatTreeFromCommitFiles(s.files, s.showRootItem)
|
||||
result := BuildFlatTreeFromCommitFiles(s.files, s.showRootItem, NodeSortComparator[models.CommitFile]("mixed", false))
|
||||
assert.EqualValues(t, s.expected, result)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,11 +107,13 @@ func (self *CommitFileTree) getFilesForDisplay() []*models.CommitFile {
|
|||
|
||||
func (self *CommitFileTree) SetTree() {
|
||||
filesForDisplay := self.getFilesForDisplay()
|
||||
showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree
|
||||
guiConfig := self.common.UserConfig().Gui
|
||||
showRootItem := guiConfig.ShowRootItemInFileTree
|
||||
cmp := NodeSortComparator[models.CommitFile](guiConfig.FileTreeSortOrder, guiConfig.FileTreeSortCaseSensitive)
|
||||
if self.showTree {
|
||||
self.tree = BuildTreeFromCommitFiles(filesForDisplay, showRootItem)
|
||||
self.tree = BuildTreeFromCommitFiles(filesForDisplay, showRootItem, cmp)
|
||||
} else {
|
||||
self.tree = BuildFlatTreeFromCommitFiles(filesForDisplay, showRootItem)
|
||||
self.tree = BuildFlatTreeFromCommitFiles(filesForDisplay, showRootItem, cmp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -180,11 +180,13 @@ func (self *FileTree) GetAllFiles() []*models.File {
|
|||
|
||||
func (self *FileTree) SetTree() {
|
||||
filesForDisplay := self.getFilesForDisplay()
|
||||
showRootItem := self.common.UserConfig().Gui.ShowRootItemInFileTree
|
||||
guiConfig := self.common.UserConfig().Gui
|
||||
showRootItem := guiConfig.ShowRootItemInFileTree
|
||||
cmp := NodeSortComparator[models.File](guiConfig.FileTreeSortOrder, guiConfig.FileTreeSortCaseSensitive)
|
||||
if self.showTree {
|
||||
self.tree = BuildTreeFromFiles(filesForDisplay, showRootItem)
|
||||
self.tree = BuildTreeFromFiles(filesForDisplay, showRootItem, cmp)
|
||||
} else {
|
||||
self.tree = BuildFlatTreeFromFiles(filesForDisplay, showRootItem)
|
||||
self.tree = BuildFlatTreeFromFiles(filesForDisplay, showRootItem, cmp)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package filetree
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
||||
"github.com/jesseduffield/lazygit/pkg/common"
|
||||
"github.com/jesseduffield/lazygit/pkg/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
|
|
@ -91,3 +94,70 @@ func TestFilterAction(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileTreeSortOrderConfig(t *testing.T) {
|
||||
// "Dir" (uppercase D), "b-file", and "Z-file" produce distinct orderings across all
|
||||
// combinations of sort order and case sensitivity:
|
||||
// ASCII order: D(68) < Z(90) < b(98)
|
||||
// Case-insensitive order: b < d < z
|
||||
files := []*models.File{
|
||||
{Path: "Dir/inner"},
|
||||
{Path: "b-file"},
|
||||
{Path: "Z-file"},
|
||||
}
|
||||
|
||||
scenarios := []struct {
|
||||
sortOrder string
|
||||
caseSensitive bool
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
sortOrder: "mixed",
|
||||
caseSensitive: true,
|
||||
expected: []string{"Dir", "Dir/inner", "Z-file", "b-file"},
|
||||
},
|
||||
{
|
||||
sortOrder: "mixed",
|
||||
caseSensitive: false,
|
||||
expected: []string{"b-file", "Dir", "Dir/inner", "Z-file"},
|
||||
},
|
||||
{
|
||||
sortOrder: "filesFirst",
|
||||
caseSensitive: true,
|
||||
expected: []string{"Z-file", "b-file", "Dir", "Dir/inner"},
|
||||
},
|
||||
{
|
||||
sortOrder: "filesFirst",
|
||||
caseSensitive: false,
|
||||
expected: []string{"b-file", "Z-file", "Dir", "Dir/inner"},
|
||||
},
|
||||
{
|
||||
sortOrder: "foldersFirst",
|
||||
caseSensitive: true,
|
||||
expected: []string{"Dir", "Dir/inner", "Z-file", "b-file"},
|
||||
},
|
||||
{
|
||||
sortOrder: "foldersFirst",
|
||||
caseSensitive: false,
|
||||
expected: []string{"Dir", "Dir/inner", "b-file", "Z-file"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.sortOrder+"/caseSensitive="+fmt.Sprintf("%v", s.caseSensitive), func(t *testing.T) {
|
||||
userConfig := config.GetDefaultConfig()
|
||||
userConfig.Gui.ShowRootItemInFileTree = false
|
||||
userConfig.Gui.FileTreeSortOrder = s.sortOrder
|
||||
userConfig.Gui.FileTreeSortCaseSensitive = s.caseSensitive
|
||||
cmn := common.NewDummyCommonWithUserConfigAndAppState(userConfig, nil)
|
||||
tree := NewFileTree(func() []*models.File { return files }, cmn, true)
|
||||
tree.SetTree()
|
||||
|
||||
paths := make([]string, tree.Len())
|
||||
for i := range tree.Len() {
|
||||
paths[i] = tree.Get(i).GetPath()
|
||||
}
|
||||
assert.Equal(t, s.expected, paths)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -63,11 +63,52 @@ func (self *Node[T]) GetInternalPath() string {
|
|||
return self.path
|
||||
}
|
||||
|
||||
func (self *Node[T]) Sort() {
|
||||
self.SortChildren()
|
||||
func (self *Node[T]) Sort(cmp func(a, b *Node[T]) int) {
|
||||
self.SortChildren(cmp)
|
||||
|
||||
for _, child := range self.Children {
|
||||
child.Sort()
|
||||
child.Sort(cmp)
|
||||
}
|
||||
}
|
||||
|
||||
// NodeSortComparator returns a comparator function for sorting tree nodes
|
||||
// based on the given sort order and case sensitivity.
|
||||
// sortOrder must be one of: "mixed", "filesFirst", "foldersFirst".
|
||||
func NodeSortComparator[T any](sortOrder string, caseSensitive bool) func(a, b *Node[T]) int {
|
||||
strCmp := strings.Compare
|
||||
if !caseSensitive {
|
||||
strCmp = func(a, b string) int {
|
||||
return strings.Compare(strings.ToLower(a), strings.ToLower(b))
|
||||
}
|
||||
}
|
||||
|
||||
// dirVsFileOrder is the return value when a is a directory and b is a file.
|
||||
// -1 means directories come first, 1 means files come first.
|
||||
dirVsFileOrder := 0
|
||||
switch sortOrder {
|
||||
case "foldersFirst":
|
||||
dirVsFileOrder = -1
|
||||
case "filesFirst":
|
||||
dirVsFileOrder = 1
|
||||
}
|
||||
|
||||
if dirVsFileOrder != 0 {
|
||||
return func(a, b *Node[T]) int {
|
||||
aIsDir := !a.IsFile()
|
||||
bIsDir := !b.IsFile()
|
||||
if aIsDir != bIsDir {
|
||||
if aIsDir {
|
||||
return dirVsFileOrder
|
||||
}
|
||||
return -dirVsFileOrder
|
||||
}
|
||||
return strCmp(a.path, b.path)
|
||||
}
|
||||
}
|
||||
|
||||
// "mixed": sort by path only
|
||||
return func(a, b *Node[T]) int {
|
||||
return strCmp(a.path, b.path)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -87,23 +128,14 @@ func (self *Node[T]) ForEachFile(cb func(*T) error) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (self *Node[T]) SortChildren() {
|
||||
func (self *Node[T]) SortChildren(cmp func(a, b *Node[T]) int) {
|
||||
if self.IsFile() {
|
||||
return
|
||||
}
|
||||
|
||||
children := slices.Clone(self.Children)
|
||||
|
||||
slices.SortFunc(children, func(a, b *Node[T]) int {
|
||||
if !a.IsFile() && b.IsFile() {
|
||||
return -1
|
||||
}
|
||||
if a.IsFile() && !b.IsFile() {
|
||||
return 1
|
||||
}
|
||||
|
||||
return strings.Compare(a.path, b.path)
|
||||
})
|
||||
slices.SortFunc(children, cmp)
|
||||
|
||||
// TODO: think about making this in-place
|
||||
self.Children = children
|
||||
|
|
|
|||
|
|
@ -50,11 +50,11 @@ func TestGetVisualDepthAtIndex(t *testing.T) {
|
|||
showRootItem: true,
|
||||
// Displayed as:
|
||||
// index 0: ▼ / (depth 0)
|
||||
// index 4: c (depth 1)
|
||||
// index 1: ▼ dir (depth 1)
|
||||
// index 2: a (depth 2)
|
||||
// index 3: b (depth 2)
|
||||
// index 4: c (depth 1)
|
||||
expectedDepths: []int{0, 1, 2, 2, 1},
|
||||
expectedDepths: []int{0, 1, 1, 2, 2},
|
||||
},
|
||||
{
|
||||
name: "compressed paths with root item",
|
||||
|
|
@ -113,7 +113,7 @@ func TestGetVisualDepthAtIndex(t *testing.T) {
|
|||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.name, func(t *testing.T) {
|
||||
tree := BuildTreeFromFiles(s.files, s.showRootItem)
|
||||
tree := BuildTreeFromFiles(s.files, s.showRootItem, NodeSortComparator[models.File]("mixed", false))
|
||||
collapsedPaths := NewCollapsedPaths()
|
||||
for _, p := range s.collapsedPaths {
|
||||
collapsedPaths.Collapse(p)
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ var DiscardOldFileChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
Lines(
|
||||
Equals("▼ /").IsSelected(),
|
||||
Equals(" ▼ dir1"),
|
||||
Equals(" A d1_file0"),
|
||||
Equals(" ▼ subd1"),
|
||||
Equals(" A subfile0"),
|
||||
Equals(" A d1_file0"),
|
||||
Equals(" ▼ dir2"),
|
||||
Equals(" A d2_file1"),
|
||||
Equals(" A d2_file2"),
|
||||
|
|
@ -65,9 +65,9 @@ var DiscardOldFileChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
Lines(
|
||||
Equals("▼ /"),
|
||||
Equals(" ▼ dir1/subd1"),
|
||||
Equals(" A subfile0"),
|
||||
Equals(" A subfile0").IsSelected(),
|
||||
Equals(" ▼ dir2"),
|
||||
Equals(" A d2_file1").IsSelected(),
|
||||
Equals(" A d2_file1"),
|
||||
Equals(" A d2_file2"),
|
||||
).
|
||||
PressEscape()
|
||||
|
|
@ -125,10 +125,10 @@ var DiscardOldFileChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
IsFocused().
|
||||
Lines(
|
||||
Equals("▼ dir1").IsSelected(),
|
||||
Equals(" ▼ subd1"),
|
||||
Equals(" A file2ToRemove"),
|
||||
Equals(" A fileToRemove"),
|
||||
Equals(" A multiLineFile"),
|
||||
Equals(" ▼ subd1"),
|
||||
Equals(" A file2ToRemove"),
|
||||
).
|
||||
NavigateToLine(Contains("multiLineFile")).
|
||||
PressEnter()
|
||||
|
|
@ -145,10 +145,10 @@ var DiscardOldFileChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
IsFocused().
|
||||
Lines(
|
||||
Equals("▼ dir1"),
|
||||
Equals(" ▼ subd1"),
|
||||
Equals(" A file2ToRemove"),
|
||||
Equals(" A fileToRemove"),
|
||||
Equals(" ◐ multiLineFile").IsSelected(),
|
||||
Equals(" ▼ subd1"),
|
||||
Equals(" A file2ToRemove"),
|
||||
).
|
||||
NavigateToLine(Contains("dir1")).
|
||||
Press(keys.Universal.ToggleRangeSelect).
|
||||
|
|
|
|||
|
|
@ -32,9 +32,9 @@ var DiscardUnstagedDirChanges = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
Lines(
|
||||
Equals("▼ /").IsSelected(),
|
||||
Equals(" ▼ dir"),
|
||||
Equals(" MM file-one"),
|
||||
Equals(" ▼ subdir"),
|
||||
Equals(" ?? unstaged-file-one"),
|
||||
Equals(" MM file-one"),
|
||||
Equals(" ?? unstaged-file-two"),
|
||||
Equals(" ?? unstaged-file-three"),
|
||||
).
|
||||
|
|
|
|||
|
|
@ -40,9 +40,9 @@ var RemoveNested = NewIntegrationTest(NewIntegrationTestArgs{
|
|||
t.Views().Files().IsFocused().
|
||||
Lines(
|
||||
Equals("▼ /").IsSelected(),
|
||||
Equals(" M .gitmodules"),
|
||||
Equals(" ▼ modules"),
|
||||
Equals(" D innerSubPath"),
|
||||
Equals(" M .gitmodules"),
|
||||
).
|
||||
NavigateToLine(Contains(".gitmodules"))
|
||||
|
||||
|
|
|
|||
|
|
@ -632,6 +632,21 @@
|
|||
"description": "If true, add a \"/\" root item in the file tree representing the root of the repository. It is only added when necessary, i.e. when there is more than one item at top level.",
|
||||
"default": true
|
||||
},
|
||||
"fileTreeSortOrder": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"mixed",
|
||||
"filesFirst",
|
||||
"foldersFirst"
|
||||
],
|
||||
"description": "How to sort files and directories in the file tree.\nOne of: 'mixed' (default) | 'filesFirst' | 'foldersFirst'",
|
||||
"default": "mixed"
|
||||
},
|
||||
"fileTreeSortCaseSensitive": {
|
||||
"type": "boolean",
|
||||
"description": "If true (default), sort the file tree case-sensitively.",
|
||||
"default": true
|
||||
},
|
||||
"showNumstatInFilesView": {
|
||||
"type": "boolean",
|
||||
"description": "If true, show the number of lines changed per file in the Files view",
|
||||
|
|
|
|||
Loading…
Reference in a new issue