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:
Stefan Haller 2026-03-30 17:55:53 +02:00 committed by GitHub
commit 050bffa580
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 199 additions and 44 deletions

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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)
})
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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)
})
}
}

View file

@ -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

View file

@ -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)

View file

@ -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).

View file

@ -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"),
).

View file

@ -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"))

View file

@ -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",