mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-11-03 20:57:01 -06:00
Make file sort order and case sensitivity configurable
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.
This commit is contained in:
1 parent
d42851d676
commit
b8d23dd5a5
14 files changed
+199
-44
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+46
-14
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user