diff --git a/docs-master/Config.md b/docs-master/Config.md index aa149e9e8..02b57eb7d 100644 --- a/docs-master/Config.md +++ b/docs-master/Config.md @@ -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 diff --git a/pkg/config/user_config.go b/pkg/config/user_config.go index 192d13843..3ed5e8d90 100644 --- a/pkg/config/user_config.go +++ b/pkg/config/user_config.go @@ -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, diff --git a/pkg/config/user_config_validation.go b/pkg/config/user_config_validation.go index 16be84521..23215eab6 100644 --- a/pkg/config/user_config_validation.go +++ b/pkg/config/user_config_validation.go @@ -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 diff --git a/pkg/gui/filetree/build_tree.go b/pkg/gui/filetree/build_tree.go index 91e6d1986..b4091bec9 100644 --- a/pkg/gui/filetree/build_tree.go +++ b/pkg/gui/filetree/build_tree.go @@ -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 diff --git a/pkg/gui/filetree/build_tree_test.go b/pkg/gui/filetree/build_tree_test.go index c3077783b..e8d9d21d3 100644 --- a/pkg/gui/filetree/build_tree_test.go +++ b/pkg/gui/filetree/build_tree_test.go @@ -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) }) } diff --git a/pkg/gui/filetree/commit_file_tree.go b/pkg/gui/filetree/commit_file_tree.go index 38232ea3c..81a196c83 100644 --- a/pkg/gui/filetree/commit_file_tree.go +++ b/pkg/gui/filetree/commit_file_tree.go @@ -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) } } diff --git a/pkg/gui/filetree/file_tree.go b/pkg/gui/filetree/file_tree.go index a70876221..2d3cec514 100644 --- a/pkg/gui/filetree/file_tree.go +++ b/pkg/gui/filetree/file_tree.go @@ -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) } } diff --git a/pkg/gui/filetree/file_tree_test.go b/pkg/gui/filetree/file_tree_test.go index 4a593711c..1c7960a6e 100644 --- a/pkg/gui/filetree/file_tree_test.go +++ b/pkg/gui/filetree/file_tree_test.go @@ -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) + }) + } +} diff --git a/pkg/gui/filetree/node.go b/pkg/gui/filetree/node.go index f6e343408..143cdeeff 100644 --- a/pkg/gui/filetree/node.go +++ b/pkg/gui/filetree/node.go @@ -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 diff --git a/pkg/gui/filetree/node_test.go b/pkg/gui/filetree/node_test.go index d44441f39..66a62414e 100644 --- a/pkg/gui/filetree/node_test.go +++ b/pkg/gui/filetree/node_test.go @@ -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) diff --git a/pkg/integration/tests/commit/discard_old_file_changes.go b/pkg/integration/tests/commit/discard_old_file_changes.go index 0268396db..2dc8006bb 100644 --- a/pkg/integration/tests/commit/discard_old_file_changes.go +++ b/pkg/integration/tests/commit/discard_old_file_changes.go @@ -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). diff --git a/pkg/integration/tests/file/discard_unstaged_dir_changes.go b/pkg/integration/tests/file/discard_unstaged_dir_changes.go index 572194572..ea2a58ef5 100644 --- a/pkg/integration/tests/file/discard_unstaged_dir_changes.go +++ b/pkg/integration/tests/file/discard_unstaged_dir_changes.go @@ -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"), ). diff --git a/pkg/integration/tests/submodule/remove_nested.go b/pkg/integration/tests/submodule/remove_nested.go index fe05c0fb0..b143096cd 100644 --- a/pkg/integration/tests/submodule/remove_nested.go +++ b/pkg/integration/tests/submodule/remove_nested.go @@ -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")) diff --git a/schema-master/config.json b/schema-master/config.json index 54f9fa9ec..3fc88d093 100644 --- a/schema-master/config.json +++ b/schema-master/config.json @@ -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",