From 36a29f225b417d993ba7497be4325cbceeffb4cb Mon Sep 17 00:00:00 2001 From: Alex March Date: Thu, 21 Dec 2023 17:57:58 +0900 Subject: [PATCH] Add a sort order menu for local branches --- docs/keybindings/Keybindings_en.md | 1 + docs/keybindings/Keybindings_ja.md | 1 + docs/keybindings/Keybindings_ko.md | 1 + docs/keybindings/Keybindings_nl.md | 1 + docs/keybindings/Keybindings_pl.md | 1 + docs/keybindings/Keybindings_ru.md | 1 + docs/keybindings/Keybindings_zh-CN.md | 1 + docs/keybindings/Keybindings_zh-TW.md | 1 + pkg/commands/git_commands/branch_loader.go | 75 ++++++++++++------- .../git_commands/branch_loader_test.go | 54 +++++++++---- pkg/config/app_config.go | 2 + pkg/gui/controllers/branches_controller.go | 18 +++++ pkg/gui/controllers/helpers/refresh_helper.go | 2 +- pkg/gui/controllers/helpers/refs_helper.go | 39 ++++++---- .../controllers/remote_branches_controller.go | 2 +- pkg/i18n/english.go | 4 + 16 files changed, 147 insertions(+), 57 deletions(-) diff --git a/docs/keybindings/Keybindings_en.md b/docs/keybindings/Keybindings_en.md index e4f6fd8be..b5dc93898 100644 --- a/docs/keybindings/Keybindings_en.md +++ b/docs/keybindings/Keybindings_en.md @@ -154,6 +154,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ M: Merge into currently checked out branch f: Fast-forward this branch from its upstream T: Create tag + s: Sort order g: View reset options R: Rename branch u: View upstream options diff --git a/docs/keybindings/Keybindings_ja.md b/docs/keybindings/Keybindings_ja.md index 16e2d8a4d..3b7ef6991 100644 --- a/docs/keybindings/Keybindings_ja.md +++ b/docs/keybindings/Keybindings_ja.md @@ -226,6 +226,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ M: 現在のブランチにマージ f: Fast-forward this branch from its upstream T: タグを作成 + s: 並び替え g: View reset options R: ブランチ名を変更 u: View upstream options diff --git a/docs/keybindings/Keybindings_ko.md b/docs/keybindings/Keybindings_ko.md index 35dd0ebc9..02b5db76e 100644 --- a/docs/keybindings/Keybindings_ko.md +++ b/docs/keybindings/Keybindings_ko.md @@ -188,6 +188,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ M: 현재 브랜치에 병합 f: Fast-forward this branch from its upstream T: 태그를 생성 + s: Sort order g: View reset options R: 브랜치 이름 변경 u: View upstream options diff --git a/docs/keybindings/Keybindings_nl.md b/docs/keybindings/Keybindings_nl.md index c5fe3f3b3..e1bddf929 100644 --- a/docs/keybindings/Keybindings_nl.md +++ b/docs/keybindings/Keybindings_nl.md @@ -96,6 +96,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ M: Merge in met huidige checked out branch f: Fast-forward deze branch vanaf zijn upstream T: Creëer tag + s: Sort order g: Bekijk reset opties R: Hernoem branch u: View upstream options diff --git a/docs/keybindings/Keybindings_pl.md b/docs/keybindings/Keybindings_pl.md index 558e3b50f..f9eb05e09 100644 --- a/docs/keybindings/Keybindings_pl.md +++ b/docs/keybindings/Keybindings_pl.md @@ -111,6 +111,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ M: Scal do obecnej gałęzi f: Fast-forward this branch from its upstream T: Create tag + s: Sort order g: Wyświetl opcje resetu R: Rename branch u: View upstream options diff --git a/docs/keybindings/Keybindings_ru.md b/docs/keybindings/Keybindings_ru.md index 22600aea7..f0a1fd4e2 100644 --- a/docs/keybindings/Keybindings_ru.md +++ b/docs/keybindings/Keybindings_ru.md @@ -186,6 +186,7 @@ _Связки клавиш_ M: Слияние с текущей переключённой веткой f: Перемотать эту ветку вперёд из её upstream-ветки T: Создать тег + s: Порядок сортировки g: Просмотреть параметры сброса R: Переименовать ветку u: View upstream options diff --git a/docs/keybindings/Keybindings_zh-CN.md b/docs/keybindings/Keybindings_zh-CN.md index 354ef952a..9b0152851 100644 --- a/docs/keybindings/Keybindings_zh-CN.md +++ b/docs/keybindings/Keybindings_zh-CN.md @@ -89,6 +89,7 @@ _Legend: `` means ctrl+b, `` means alt+b, `B` means shift+b_ M: 合并到当前检出的分支 f: 从上游快进此分支 T: 创建标签 + s: Sort order g: 查看重置选项 R: 重命名分支 u: View upstream options diff --git a/docs/keybindings/Keybindings_zh-TW.md b/docs/keybindings/Keybindings_zh-TW.md index 6e3094afd..866525581 100644 --- a/docs/keybindings/Keybindings_zh-TW.md +++ b/docs/keybindings/Keybindings_zh-TW.md @@ -261,6 +261,7 @@ _說明:`` 表示 Ctrl+B、`` 表示 Alt+B,`B`表示 Shift+B_ M: 合併到當前檢出的分支 f: 從上游快進此分支 T: 建立標籤 + s: Sort order g: 檢視重設選項 R: 重新命名分支 u: View upstream options diff --git a/pkg/commands/git_commands/branch_loader.go b/pkg/commands/git_commands/branch_loader.go index b7e55a910..198368502 100644 --- a/pkg/commands/git_commands/branch_loader.go +++ b/pkg/commands/git_commands/branch_loader.go @@ -3,6 +3,7 @@ package git_commands import ( "fmt" "regexp" + "strconv" "strings" "github.com/jesseduffield/generics/set" @@ -62,33 +63,34 @@ func NewBranchLoader( func (self *BranchLoader) Load(reflogCommits []*models.Commit) ([]*models.Branch, error) { branches := self.obtainBranches() - reflogBranches := self.obtainReflogBranches(reflogCommits) - - // loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches - branchesWithRecency := make([]*models.Branch, 0) -outer: - for _, reflogBranch := range reflogBranches { - for j, branch := range branches { - if branch.Head { - continue - } - if strings.EqualFold(reflogBranch.Name, branch.Name) { - branch.Recency = reflogBranch.Recency - branchesWithRecency = append(branchesWithRecency, branch) - branches = utils.Remove(branches, j) - continue outer + if self.AppState.LocalBranchSortOrder == "recency" { + reflogBranches := self.obtainReflogBranches(reflogCommits) + // loop through reflog branches. If there is a match, merge them, then remove it from the branches and keep it in the reflog branches + branchesWithRecency := make([]*models.Branch, 0) + outer: + for _, reflogBranch := range reflogBranches { + for j, branch := range branches { + if branch.Head { + continue + } + if strings.EqualFold(reflogBranch.Name, branch.Name) { + branch.Recency = reflogBranch.Recency + branchesWithRecency = append(branchesWithRecency, branch) + branches = utils.Remove(branches, j) + continue outer + } } } + + // Sort branches that don't have a recency value alphabetically + // (we're really doing this for the sake of deterministic behaviour across git versions) + slices.SortFunc(branches, func(a *models.Branch, b *models.Branch) bool { + return a.Name < b.Name + }) + + branches = utils.Prepend(branches, branchesWithRecency...) } - // Sort branches that don't have a recency value alphabetically - // (we're really doing this for the sake of deterministic behaviour across git versions) - slices.SortFunc(branches, func(a *models.Branch, b *models.Branch) bool { - return a.Name < b.Name - }) - - branches = utils.Prepend(branches, branchesWithRecency...) - foundHead := false for i, branch := range branches { if branch.Head { @@ -144,7 +146,8 @@ func (self *BranchLoader) obtainBranches() []*models.Branch { return nil, false } - return obtainBranch(split), true + storeCommitDateAsRecency := self.AppState.LocalBranchSortOrder != "recency" + return obtainBranch(split, storeCommitDateAsRecency), true }) } @@ -156,8 +159,18 @@ func (self *BranchLoader) getRawBranches() (string, error) { "%00", ) + var sortOrder string + switch strings.ToLower(self.AppState.LocalBranchSortOrder) { + case "recency", "date": + sortOrder = "-committerdate" + case "alphabetical": + sortOrder = "refname" + default: + sortOrder = "refname" + } + cmdArgs := NewGitCmd("for-each-ref"). - Arg("--sort=-committerdate"). + Arg(fmt.Sprintf("--sort=%s", sortOrder)). Arg(fmt.Sprintf("--format=%s", format)). Arg("refs/heads"). ToArgv() @@ -172,22 +185,32 @@ var branchFields = []string{ "upstream:track", "subject", "objectname", + "committerdate:unix", } // Obtain branch information from parsed line output of getRawBranches() -func obtainBranch(split []string) *models.Branch { +func obtainBranch(split []string, storeCommitDateAsRecency bool) *models.Branch { headMarker := split[0] fullName := split[1] upstreamName := split[2] track := split[3] subject := split[4] commitHash := split[5] + commitDate := split[6] name := strings.TrimPrefix(fullName, "heads/") pushables, pullables, gone := parseUpstreamInfo(upstreamName, track) + recency := "" + if storeCommitDateAsRecency { + if unixTimestamp, err := strconv.ParseInt(commitDate, 10, 64); err == nil { + recency = utils.UnixToTimeAgo(unixTimestamp) + } + } + return &models.Branch{ Name: name, + Recency: recency, Pushables: pushables, Pullables: pullables, UpstreamGone: gone, diff --git a/pkg/commands/git_commands/branch_loader_test.go b/pkg/commands/git_commands/branch_loader_test.go index f16dcf5f4..9e56666fe 100644 --- a/pkg/commands/git_commands/branch_loader_test.go +++ b/pkg/commands/git_commands/branch_loader_test.go @@ -2,7 +2,9 @@ package git_commands // "*|feat/detect-purge|origin/feat/detect-purge|[ahead 1]" import ( + "strconv" "testing" + "time" "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/stretchr/testify/assert" @@ -10,15 +12,21 @@ import ( func TestObtainBranch(t *testing.T) { type scenario struct { - testName string - input []string - expectedBranch *models.Branch + testName string + input []string + storeCommitDateAsRecency bool + expectedBranch *models.Branch } + // Use a time stamp of 2 1/2 hours ago, resulting in a recency string of "2h" + now := time.Now().Unix() + timeStamp := strconv.Itoa(int(now - 2.5*60*60)) + scenarios := []scenario{ { - testName: "TrimHeads", - input: []string{"", "heads/a_branch", "", "", "subject", "123"}, + testName: "TrimHeads", + input: []string{"", "heads/a_branch", "", "", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", Pushables: "?", @@ -29,8 +37,9 @@ func TestObtainBranch(t *testing.T) { }, }, { - testName: "NoUpstream", - input: []string{"", "a_branch", "", "", "subject", "123"}, + testName: "NoUpstream", + input: []string{"", "a_branch", "", "", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", Pushables: "?", @@ -41,8 +50,9 @@ func TestObtainBranch(t *testing.T) { }, }, { - testName: "IsHead", - input: []string{"*", "a_branch", "", "", "subject", "123"}, + testName: "IsHead", + input: []string{"*", "a_branch", "", "", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", Pushables: "?", @@ -53,8 +63,9 @@ func TestObtainBranch(t *testing.T) { }, }, { - testName: "IsBehindAndAhead", - input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "subject", "123"}, + testName: "IsBehindAndAhead", + input: []string{"", "a_branch", "a_remote/a_branch", "[behind 2, ahead 3]", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", Pushables: "3", @@ -65,8 +76,9 @@ func TestObtainBranch(t *testing.T) { }, }, { - testName: "RemoteBranchIsGone", - input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "subject", "123"}, + testName: "RemoteBranchIsGone", + input: []string{"", "a_branch", "a_remote/a_branch", "[gone]", "subject", "123", timeStamp}, + storeCommitDateAsRecency: false, expectedBranch: &models.Branch{ Name: "a_branch", UpstreamGone: true, @@ -77,11 +89,25 @@ func TestObtainBranch(t *testing.T) { CommitHash: "123", }, }, + { + testName: "WithCommitDateAsRecency", + input: []string{"", "a_branch", "", "", "subject", "123", timeStamp}, + storeCommitDateAsRecency: true, + expectedBranch: &models.Branch{ + Name: "a_branch", + Recency: "2h", + Pushables: "?", + Pullables: "?", + Head: false, + Subject: "subject", + CommitHash: "123", + }, + }, } for _, s := range scenarios { t.Run(s.testName, func(t *testing.T) { - branch := obtainBranch(s.input) + branch := obtainBranch(s.input, s.storeCommitDateAsRecency) assert.EqualValues(t, s.expectedBranch, branch) }) } diff --git a/pkg/config/app_config.go b/pkg/config/app_config.go index 927f9f309..b6366cd2d 100644 --- a/pkg/config/app_config.go +++ b/pkg/config/app_config.go @@ -323,6 +323,7 @@ type AppState struct { HideCommandLog bool IgnoreWhitespaceInDiffView bool DiffContextSize int + LocalBranchSortOrder string RemoteBranchSortOrder string } @@ -332,6 +333,7 @@ func getDefaultAppState() *AppState { RecentRepos: []string{}, StartupPopupVersion: 0, DiffContextSize: 3, + LocalBranchSortOrder: "recency", RemoteBranchSortOrder: "alphabetical", } } diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 3de005a2b..22390fc8e 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -97,6 +97,12 @@ func (self *BranchesController) GetKeybindings(opts types.KeybindingsOpts) []*ty Handler: self.checkSelected(self.createTag), Description: self.c.Tr.CreateTag, }, + { + Key: opts.GetKey(opts.Config.Branches.SortOrder), + Handler: self.createSortMenu, + Description: self.c.Tr.SortOrder, + OpensMenu: true, + }, { Key: opts.GetKey(opts.Config.Commits.ViewResetOptions), Handler: self.checkSelected(self.createResetMenu), @@ -617,6 +623,18 @@ func (self *BranchesController) createTag(branch *models.Branch) error { return self.c.Helpers().Tags.OpenCreateTagPrompt(branch.FullRefName(), func() {}) } +func (self *BranchesController) createSortMenu() error { + return self.c.Helpers().Refs.CreateSortOrderMenu([]string{"recency", "alphabetical", "date"}, func(sortOrder string) error { + if self.c.GetAppState().LocalBranchSortOrder != sortOrder { + self.c.GetAppState().LocalBranchSortOrder = sortOrder + self.c.SaveAppStateAndLogError() + self.c.Contexts().Branches.SetSelectedLineIdx(0) + return self.c.Refresh(types.RefreshOptions{Mode: types.ASYNC, Scope: []types.RefreshableView{types.BRANCHES}}) + } + return nil + }) +} + func (self *BranchesController) createResetMenu(selectedBranch *models.Branch) error { return self.c.Helpers().Refs.CreateGitResetMenu(selectedBranch.Name) } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index dc43844e2..15ecd8d4e 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -430,7 +430,7 @@ func (self *RefreshHelper) refreshBranches(refreshWorktrees bool) { defer self.c.Mutexes().RefreshingBranchesMutex.Unlock() reflogCommits := self.c.Model().FilteredReflogCommits - if self.c.Modes().Filtering.Active() { + if self.c.Modes().Filtering.Active() && self.c.AppState.LocalBranchSortOrder == "recency" { // in filter mode we filter our reflog commits to just those containing the path // however we need all the reflog entries to populate the recencies of our branches // which allows us to order them correctly. So if we're filtering we'll just diff --git a/pkg/gui/controllers/helpers/refs_helper.go b/pkg/gui/controllers/helpers/refs_helper.go index b4617c8a0..6d0d64983 100644 --- a/pkg/gui/controllers/helpers/refs_helper.go +++ b/pkg/gui/controllers/helpers/refs_helper.go @@ -119,31 +119,40 @@ func (self *RefsHelper) ResetToRef(ref string, strength string, envVars []string return nil } -func (self *RefsHelper) CreateSortOrderMenu(onSelected func(sortOrder string) error) error { - type sortOrderWithKey struct { - key types.Key - label string - sortKey string - sortOrder string +func (self *RefsHelper) CreateSortOrderMenu(sortOptionsOrder []string, onSelected func(sortOrder string) error) error { + type sortMenuOption struct { + key types.Key + label string + description string + sortOrder string } - sortKeys := []sortOrderWithKey{ - {label: self.c.Tr.SortAlphabetical, sortKey: "refname", sortOrder: "alphabetical", key: 'a'}, - {label: self.c.Tr.SortByDate, sortKey: "-committerdate", sortOrder: "date", key: 'd'}, + availableSortOptions := map[string]sortMenuOption{ + "recency": {label: self.c.Tr.SortByRecency, description: self.c.Tr.SortBasedOnReflog, key: 'r'}, + "alphabetical": {label: self.c.Tr.SortAlphabetical, description: "--sort=refname", key: 'a'}, + "date": {label: self.c.Tr.SortByDate, description: "--sort=-committerdate", key: 'd'}, + } + sortOptions := make([]sortMenuOption, 0, len(sortOptionsOrder)) + for _, key := range sortOptionsOrder { + sortOption, ok := availableSortOptions[key] + if !ok { + panic(fmt.Sprintf("unexpected sort order: %s", key)) + } + sortOption.sortOrder = key + sortOptions = append(sortOptions, sortOption) } - menuItems := lo.Map(sortKeys, func(row sortOrderWithKey, _ int) *types.MenuItem { + menuItems := lo.Map(sortOptions, func(opt sortMenuOption, _ int) *types.MenuItem { return &types.MenuItem{ LabelColumns: []string{ - row.label, - style.FgYellow.Sprintf("--sort=%s", row.sortKey), + opt.label, + style.FgYellow.Sprint(opt.description), }, OnPress: func() error { - return onSelected(row.sortOrder) + return onSelected(opt.sortOrder) }, - Key: row.key, + Key: opt.key, } }) - return self.c.Menu(types.CreateMenuOptions{ Title: self.c.Tr.SortOrder, Items: menuItems, diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 6d2244746..04afd6415 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -128,7 +128,7 @@ func (self *RemoteBranchesController) rebase(selectedBranch *models.RemoteBranch } func (self *RemoteBranchesController) createSortMenu() error { - return self.c.Helpers().Refs.CreateSortOrderMenu(func(sortOrder string) error { + return self.c.Helpers().Refs.CreateSortOrderMenu([]string{"alphabetical", "date"}, func(sortOrder string) error { if self.c.GetAppState().RemoteBranchSortOrder != sortOrder { self.c.GetAppState().RemoteBranchSortOrder = sortOrder self.c.SaveAppStateAndLogError() diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 260976d64..80e89ceff 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -558,6 +558,8 @@ type TranslationSet struct { SortOrder string SortAlphabetical string SortByDate string + SortByRecency string + SortBasedOnReflog string SortCommits string CantChangeContextSizeError string OpenCommitInBrowser string @@ -1370,6 +1372,8 @@ func EnglishTranslationSet() TranslationSet { SortOrder: "Sort order", SortAlphabetical: "Alphabetical", SortByDate: "Date", + SortByRecency: "Recency", + SortBasedOnReflog: "(based on reflog)", SortCommits: "Commit sort order", CantChangeContextSizeError: "Cannot change context while in patch building mode because we were too lazy to support it when releasing the feature. If you really want it, please let us know!", OpenCommitInBrowser: "Open commit in browser",