From a5f3515ad87f978c24d9454d45a454d824eb0897 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Tue, 16 Jan 2024 17:28:14 +1100 Subject: [PATCH 1/4] Set groundwork for better disabled reasons with range select Something dumb that we're currently doing is expecting list items to define an ID method which returns a string. We use that when copying items to clipboard with ctrl+o and when getting a ref name for diffing. This commit gets us a little deeper into that hole by explicitly requiring list items to implement that method so that we can easily use the new helper functions in list_controller_trait.go. In future we need to just remove the whole ID thing entirely but I'm too lazy to do that right now. --- pkg/gui/context/branches_context.go | 9 --- pkg/gui/context/commit_files_context.go | 9 --- pkg/gui/context/filtered_list_view_model.go | 4 +- pkg/gui/context/list_renderer_test.go | 41 ++++++---- pkg/gui/context/list_view_model.go | 37 ++++++++- pkg/gui/context/local_commits_context.go | 9 --- pkg/gui/context/menu_context.go | 10 --- pkg/gui/context/reflog_commits_context.go | 9 --- pkg/gui/context/remote_branches_context.go | 20 ++--- pkg/gui/context/remotes_context.go | 9 --- pkg/gui/context/stash_context.go | 9 --- pkg/gui/context/sub_commits_context.go | 9 --- pkg/gui/context/submodules_context.go | 9 --- pkg/gui/context/suggestions_context.go | 9 --- pkg/gui/context/tags_context.go | 9 --- pkg/gui/context/working_tree_context.go | 9 --- pkg/gui/context/worktrees_context.go | 9 --- .../controllers/basic_commits_controller.go | 2 + pkg/gui/controllers/bisect_controller.go | 1 + pkg/gui/controllers/branches_controller.go | 1 + .../controllers/commits_files_controller.go | 1 + pkg/gui/controllers/files_controller.go | 1 + .../controllers/files_remove_controller.go | 1 + pkg/gui/controllers/git_flow_controller.go | 1 + pkg/gui/controllers/list_controller_trait.go | 79 ++++++++++++++++--- pkg/gui/controllers/menu_controller.go | 1 + .../controllers/reflog_commits_controller.go | 1 + .../controllers/remote_branches_controller.go | 1 + pkg/gui/controllers/remotes_controller.go | 1 + pkg/gui/controllers/stash_controller.go | 1 + pkg/gui/controllers/sub_commits_controller.go | 1 + pkg/gui/controllers/submodules_controller.go | 1 + pkg/gui/controllers/suggestions_controller.go | 1 + .../switch_to_diff_files_controller.go | 3 + .../switch_to_sub_commits_controller.go | 3 + pkg/gui/controllers/tags_controller.go | 1 + .../worktree_options_controller.go | 1 + pkg/gui/controllers/worktrees_controller.go | 1 + .../filetree/commit_file_tree_view_model.go | 24 ++++++ pkg/gui/filetree/file_tree_view_model.go | 31 ++++++++ pkg/gui/types/common.go | 6 ++ pkg/gui/types/context.go | 1 + pkg/gui/types/suggestion.go | 5 ++ 43 files changed, 237 insertions(+), 154 deletions(-) diff --git a/pkg/gui/context/branches_context.go b/pkg/gui/context/branches_context.go index 5905168ea..d2647ef84 100644 --- a/pkg/gui/context/branches_context.go +++ b/pkg/gui/context/branches_context.go @@ -59,15 +59,6 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext { return self } -func (self *BranchesContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *BranchesContext) GetSelectedRef() types.Ref { branch := self.GetSelected() if branch == nil { diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index fbfff7144..7af968fb7 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -72,15 +72,6 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { return ctx } -func (self *CommitFilesContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *CommitFilesContext) GetDiffTerminals() []string { return []string{self.GetRef().RefName()} } diff --git a/pkg/gui/context/filtered_list_view_model.go b/pkg/gui/context/filtered_list_view_model.go index c8abbe4a1..2c2841964 100644 --- a/pkg/gui/context/filtered_list_view_model.go +++ b/pkg/gui/context/filtered_list_view_model.go @@ -1,12 +1,12 @@ package context -type FilteredListViewModel[T any] struct { +type FilteredListViewModel[T HasID] struct { *FilteredList[T] *ListViewModel[T] *SearchHistory } -func NewFilteredListViewModel[T any](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] { +func NewFilteredListViewModel[T HasID](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] { filteredList := NewFilteredList(getList, getFilterFields) self := &FilteredListViewModel[T]{ diff --git a/pkg/gui/context/list_renderer_test.go b/pkg/gui/context/list_renderer_test.go index 98e3f60aa..99c476427 100644 --- a/pkg/gui/context/list_renderer_test.go +++ b/pkg/gui/context/list_renderer_test.go @@ -9,10 +9,17 @@ import ( "github.com/stretchr/testify/assert" ) +// wrapping string in my own type to give it an ID method which is required for list items +type mystring string + +func (self mystring) ID() string { + return string(self) +} + func TestListRenderer_renderLines(t *testing.T) { scenarios := []struct { name string - modelStrings []string + modelStrings []mystring nonModelIndices []int startIdx int endIdx int @@ -20,7 +27,7 @@ func TestListRenderer_renderLines(t *testing.T) { }{ { name: "Render whole list", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, startIdx: 0, endIdx: 3, expectedOutput: ` @@ -30,7 +37,7 @@ func TestListRenderer_renderLines(t *testing.T) { }, { name: "Partial list, beginning", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, startIdx: 0, endIdx: 2, expectedOutput: ` @@ -39,7 +46,7 @@ func TestListRenderer_renderLines(t *testing.T) { }, { name: "Partial list, end", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, startIdx: 1, endIdx: 3, expectedOutput: ` @@ -48,7 +55,7 @@ func TestListRenderer_renderLines(t *testing.T) { }, { name: "Pass an endIdx greater than the model length", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, startIdx: 2, endIdx: 5, expectedOutput: ` @@ -56,7 +63,7 @@ func TestListRenderer_renderLines(t *testing.T) { }, { name: "Whole list with section headers", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{1, 3}, startIdx: 0, endIdx: 5, @@ -69,7 +76,7 @@ func TestListRenderer_renderLines(t *testing.T) { }, { name: "Multiple consecutive headers", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{0, 0, 2, 2, 2}, startIdx: 0, endIdx: 8, @@ -85,7 +92,7 @@ func TestListRenderer_renderLines(t *testing.T) { }, { name: "Partial list with headers, beginning", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{1, 3}, startIdx: 0, endIdx: 3, @@ -96,7 +103,7 @@ func TestListRenderer_renderLines(t *testing.T) { }, { name: "Partial list with headers, end (beyond end index)", - modelStrings: []string{"a", "b", "c"}, + modelStrings: []mystring{"a", "b", "c"}, nonModelIndices: []int{1, 3}, startIdx: 2, endIdx: 7, @@ -108,7 +115,7 @@ func TestListRenderer_renderLines(t *testing.T) { } for _, s := range scenarios { t.Run(s.name, func(t *testing.T) { - viewModel := NewListViewModel[string](func() []string { return s.modelStrings }) + viewModel := NewListViewModel[mystring](func() []mystring { return s.modelStrings }) var getNonModelItems func() []*NonModelItem if s.nonModelIndices != nil { getNonModelItems = func() []*NonModelItem { @@ -124,7 +131,7 @@ func TestListRenderer_renderLines(t *testing.T) { list: viewModel, getDisplayStrings: func(startIdx int, endIdx int) [][]string { return lo.Map(s.modelStrings[startIdx:endIdx], - func(s string, _ int) []string { return []string{s} }) + func(s mystring, _ int) []string { return []string{string(s)} }) }, getNonModelItems: getNonModelItems, } @@ -138,6 +145,12 @@ func TestListRenderer_renderLines(t *testing.T) { } } +type myint int + +func (self myint) ID() string { + return fmt.Sprint(int(self)) +} + func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { scenarios := []struct { name string @@ -222,8 +235,8 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { assert.Equal(t, len(s.modelIndices), len(s.expectedViewIndices)) assert.Equal(t, len(s.viewIndices), len(s.expectedModelIndices)) - modelInts := lo.Range(s.numModelItems) - viewModel := NewListViewModel[int](func() []int { return modelInts }) + modelInts := lo.Map(lo.Range(s.numModelItems), func(i int, _ int) myint { return myint(i) }) + viewModel := NewListViewModel[myint](func() []myint { return modelInts }) var getNonModelItems func() []*NonModelItem if s.nonModelIndices != nil { getNonModelItems = func() []*NonModelItem { @@ -236,7 +249,7 @@ func TestListRenderer_ModelIndexToViewIndex_and_back(t *testing.T) { list: viewModel, getDisplayStrings: func(startIdx int, endIdx int) [][]string { return lo.Map(modelInts[startIdx:endIdx], - func(i int, _ int) []string { return []string{fmt.Sprint(i)} }) + func(i myint, _ int) []string { return []string{fmt.Sprint(i)} }) }, getNonModelItems: getNonModelItems, } diff --git a/pkg/gui/context/list_view_model.go b/pkg/gui/context/list_view_model.go index 22416bff1..bf8c80e23 100644 --- a/pkg/gui/context/list_view_model.go +++ b/pkg/gui/context/list_view_model.go @@ -3,14 +3,19 @@ package context import ( "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) -type ListViewModel[T any] struct { +type HasID interface { + ID() string +} + +type ListViewModel[T HasID] struct { *traits.ListCursor getModel func() []T } -func NewListViewModel[T any](getModel func() []T) *ListViewModel[T] { +func NewListViewModel[T HasID](getModel func() []T) *ListViewModel[T] { self := &ListViewModel[T]{ getModel: getModel, } @@ -32,6 +37,34 @@ func (self *ListViewModel[T]) GetSelected() T { return self.getModel()[self.GetSelectedLineIdx()] } +func (self *ListViewModel[T]) GetSelectedItemId() string { + if self.Len() == 0 { + return "" + } + + return self.GetSelected().ID() +} + +func (self *ListViewModel[T]) GetSelectedItems() ([]T, int, int) { + if self.Len() == 0 { + return nil, -1, -1 + } + + startIdx, endIdx := self.GetSelectionRange() + + return self.getModel()[startIdx : endIdx+1], startIdx, endIdx +} + +func (self *ListViewModel[T]) GetSelectedItemIds() ([]string, int, int) { + selectedItems, startIdx, endIdx := self.GetSelectedItems() + + ids := lo.Map(selectedItems, func(item T, _ int) string { + return item.ID() + }) + + return ids, startIdx, endIdx +} + func (self *ListViewModel[T]) GetItems() []T { return self.getModel() } diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 61a40b30b..5ff361e09 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -92,15 +92,6 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { return ctx } -func (self *LocalCommitsContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - type LocalCommitsViewModel struct { *ListViewModel[*models.Commit] diff --git a/pkg/gui/context/menu_context.go b/pkg/gui/context/menu_context.go index 131aa8665..bb1060de6 100644 --- a/pkg/gui/context/menu_context.go +++ b/pkg/gui/context/menu_context.go @@ -45,16 +45,6 @@ func NewMenuContext( } } -// TODO: remove this thing. -func (self *MenuContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.Label -} - type MenuViewModel struct { c *ContextCommon menuItems []*types.MenuItem diff --git a/pkg/gui/context/reflog_commits_context.go b/pkg/gui/context/reflog_commits_context.go index 8dc52cde7..65137d633 100644 --- a/pkg/gui/context/reflog_commits_context.go +++ b/pkg/gui/context/reflog_commits_context.go @@ -59,15 +59,6 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext { } } -func (self *ReflogCommitsContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *ReflogCommitsContext) CanRebase() bool { return false } diff --git a/pkg/gui/context/remote_branches_context.go b/pkg/gui/context/remote_branches_context.go index 82d37b613..884d3debb 100644 --- a/pkg/gui/context/remote_branches_context.go +++ b/pkg/gui/context/remote_branches_context.go @@ -4,6 +4,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" ) type RemoteBranchesContext struct { @@ -53,15 +54,6 @@ func NewRemoteBranchesContext( } } -func (self *RemoteBranchesContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *RemoteBranchesContext) GetSelectedRef() types.Ref { remoteBranch := self.GetSelected() if remoteBranch == nil { @@ -70,6 +62,16 @@ func (self *RemoteBranchesContext) GetSelectedRef() types.Ref { return remoteBranch } +func (self *RemoteBranchesContext) GetSelectedRefs() ([]types.Ref, int, int) { + items, startIdx, endIdx := self.GetSelectedItems() + + refs := lo.Map(items, func(item *models.RemoteBranch, _ int) types.Ref { + return item + }) + + return refs, startIdx, endIdx +} + func (self *RemoteBranchesContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() diff --git a/pkg/gui/context/remotes_context.go b/pkg/gui/context/remotes_context.go index 035fb2321..ec59d5fd7 100644 --- a/pkg/gui/context/remotes_context.go +++ b/pkg/gui/context/remotes_context.go @@ -47,15 +47,6 @@ func NewRemotesContext(c *ContextCommon) *RemotesContext { } } -func (self *RemotesContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *RemotesContext) GetDiffTerminals() []string { itemId := self.GetSelectedItemId() diff --git a/pkg/gui/context/stash_context.go b/pkg/gui/context/stash_context.go index 2b86d945f..c8d487688 100644 --- a/pkg/gui/context/stash_context.go +++ b/pkg/gui/context/stash_context.go @@ -49,15 +49,6 @@ func NewStashContext( } } -func (self *StashContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *StashContext) CanRebase() bool { return false } diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index 1f795b44d..7a797e61d 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -175,15 +175,6 @@ func (self *SubCommitsViewModel) GetShowBranchHeads() bool { return self.showBranchHeads } -func (self *SubCommitsContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *SubCommitsContext) CanRebase() bool { return false } diff --git a/pkg/gui/context/submodules_context.go b/pkg/gui/context/submodules_context.go index 2cffd82d6..82deb25af 100644 --- a/pkg/gui/context/submodules_context.go +++ b/pkg/gui/context/submodules_context.go @@ -43,12 +43,3 @@ func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext { }, } } - -func (self *SubmodulesContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} diff --git a/pkg/gui/context/suggestions_context.go b/pkg/gui/context/suggestions_context.go index 30781fce1..59908fe5e 100644 --- a/pkg/gui/context/suggestions_context.go +++ b/pkg/gui/context/suggestions_context.go @@ -63,15 +63,6 @@ func NewSuggestionsContext( } } -func (self *SuggestionsContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.Value -} - func (self *SuggestionsContext) SetSuggestions(suggestions []*types.Suggestion) { self.State.Suggestions = suggestions self.SetSelection(0) diff --git a/pkg/gui/context/tags_context.go b/pkg/gui/context/tags_context.go index 3da5a9576..d827564dd 100644 --- a/pkg/gui/context/tags_context.go +++ b/pkg/gui/context/tags_context.go @@ -52,15 +52,6 @@ func NewTagsContext( } } -func (self *TagsContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} - func (self *TagsContext) GetSelectedRef() types.Ref { tag := self.GetSelected() if tag == nil { diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index f3bc91929..6fa462cb1 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -58,12 +58,3 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { return ctx } - -func (self *WorkingTreeContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} diff --git a/pkg/gui/context/worktrees_context.go b/pkg/gui/context/worktrees_context.go index c616dd49e..3e45f2d45 100644 --- a/pkg/gui/context/worktrees_context.go +++ b/pkg/gui/context/worktrees_context.go @@ -46,12 +46,3 @@ func NewWorktreesContext(c *ContextCommon) *WorktreesContext { }, } } - -func (self *WorktreesContext) GetSelectedItemId() string { - item := self.GetSelected() - if item == nil { - return "" - } - - return item.ID() -} diff --git a/pkg/gui/controllers/basic_commits_controller.go b/pkg/gui/controllers/basic_commits_controller.go index 386877b4d..6c378ecf0 100644 --- a/pkg/gui/controllers/basic_commits_controller.go +++ b/pkg/gui/controllers/basic_commits_controller.go @@ -16,6 +16,7 @@ type ContainsCommits interface { types.Context types.IListContext GetSelected() *models.Commit + GetSelectedItems() ([]*models.Commit, int, int) GetCommits() []*models.Commit GetSelectedLineIdx() int } @@ -36,6 +37,7 @@ func NewBasicCommitsController(c *ControllerCommon, context ContainsCommits) *Ba c, context, context.GetSelected, + context.GetSelectedItems, ), } } diff --git a/pkg/gui/controllers/bisect_controller.go b/pkg/gui/controllers/bisect_controller.go index deb4f1b7a..2f9a7ec36 100644 --- a/pkg/gui/controllers/bisect_controller.go +++ b/pkg/gui/controllers/bisect_controller.go @@ -30,6 +30,7 @@ func NewBisectController( c, c.Contexts().LocalCommits, c.Contexts().LocalCommits.GetSelected, + c.Contexts().LocalCommits.GetSelectedItems, ), } } diff --git a/pkg/gui/controllers/branches_controller.go b/pkg/gui/controllers/branches_controller.go index 8cac9537d..dbd15ef93 100644 --- a/pkg/gui/controllers/branches_controller.go +++ b/pkg/gui/controllers/branches_controller.go @@ -33,6 +33,7 @@ func NewBranchesController( c, c.Contexts().Branches, c.Contexts().Branches.GetSelected, + c.Contexts().Branches.GetSelectedItems, ), } } diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index a5333e448..b3c628cf5 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -28,6 +28,7 @@ func NewCommitFilesController( c, c.Contexts().CommitFiles, c.Contexts().CommitFiles.GetSelected, + c.Contexts().CommitFiles.GetSelectedItems, ), } } diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 3d418bf8e..ed2c5c28b 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -28,6 +28,7 @@ func NewFilesController( c, c.Contexts().Files, c.Contexts().Files.GetSelected, + c.Contexts().Files.GetSelectedItems, ), } } diff --git a/pkg/gui/controllers/files_remove_controller.go b/pkg/gui/controllers/files_remove_controller.go index 9b21557de..57314c61b 100644 --- a/pkg/gui/controllers/files_remove_controller.go +++ b/pkg/gui/controllers/files_remove_controller.go @@ -28,6 +28,7 @@ func NewFilesRemoveController( c, c.Contexts().Files, c.Contexts().Files.GetSelected, + c.Contexts().Files.GetSelectedItems, ), } } diff --git a/pkg/gui/controllers/git_flow_controller.go b/pkg/gui/controllers/git_flow_controller.go index c8da4bd0c..45ce0a5d0 100644 --- a/pkg/gui/controllers/git_flow_controller.go +++ b/pkg/gui/controllers/git_flow_controller.go @@ -25,6 +25,7 @@ func NewGitFlowController( c, c.Contexts().Branches, c.Contexts().Branches.GetSelected, + c.Contexts().Branches.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/list_controller_trait.go b/pkg/gui/controllers/list_controller_trait.go index fa60223b9..0edaa0114 100644 --- a/pkg/gui/controllers/list_controller_trait.go +++ b/pkg/gui/controllers/list_controller_trait.go @@ -6,20 +6,23 @@ import "github.com/jesseduffield/lazygit/pkg/gui/types" // ensuring a single item is selected, etc. type ListControllerTrait[T comparable] struct { - c *ControllerCommon - context types.IListContext - getSelected func() T + c *ControllerCommon + context types.IListContext + getSelectedItem func() T + getSelectedItems func() ([]T, int, int) } func NewListControllerTrait[T comparable]( c *ControllerCommon, context types.IListContext, getSelected func() T, + getSelectedItems func() ([]T, int, int), ) *ListControllerTrait[T] { return &ListControllerTrait[T]{ - c: c, - context: context, - getSelected: getSelected, + c: c, + context: context, + getSelectedItem: getSelected, + getSelectedItems: getSelectedItems, } } @@ -47,7 +50,7 @@ func (self *ListControllerTrait[T]) singleItemSelected(callbacks ...func(T) *typ } var zeroValue T - item := self.getSelected() + item := self.getSelectedItem() if item == zeroValue { return &types.DisabledReason{Text: self.c.Tr.NoItemSelected} } @@ -62,11 +65,46 @@ func (self *ListControllerTrait[T]) singleItemSelected(callbacks ...func(T) *typ } } +// Ensures that at least one item is selected. +func (self *ListControllerTrait[T]) itemRangeSelected(callbacks ...func([]T, int, int) *types.DisabledReason) func() *types.DisabledReason { + return func() *types.DisabledReason { + items, startIdx, endIdx := self.getSelectedItems() + if len(items) == 0 { + return &types.DisabledReason{Text: self.c.Tr.NoItemSelected} + } + + for _, callback := range callbacks { + if reason := callback(items, startIdx, endIdx); reason != nil { + return reason + } + } + + return nil + } +} + +func (self *ListControllerTrait[T]) itemsSelected(callbacks ...func([]T) *types.DisabledReason) func() *types.DisabledReason { //nolint:unused + return func() *types.DisabledReason { + items, _, _ := self.getSelectedItems() + if len(items) == 0 { + return &types.DisabledReason{Text: self.c.Tr.NoItemSelected} + } + + for _, callback := range callbacks { + if reason := callback(items); reason != nil { + return reason + } + } + + return nil + } +} + // Passes the selected item to the callback. Used for handler functions. func (self *ListControllerTrait[T]) withItem(callback func(T) error) func() error { return func() error { var zeroValue T - commit := self.getSelected() + commit := self.getSelectedItem() if commit == zeroValue { return self.c.ErrorMsg(self.c.Tr.NoItemSelected) } @@ -75,12 +113,35 @@ func (self *ListControllerTrait[T]) withItem(callback func(T) error) func() erro } } +func (self *ListControllerTrait[T]) withItems(callback func([]T) error) func() error { + return func() error { + items, _, _ := self.getSelectedItems() + if len(items) == 0 { + return self.c.ErrorMsg(self.c.Tr.NoItemSelected) + } + + return callback(items) + } +} + +// like withItems but also passes the start and end index of the selection +func (self *ListControllerTrait[T]) withItemsRange(callback func([]T, int, int) error) func() error { + return func() error { + items, startIdx, endIdx := self.getSelectedItems() + if len(items) == 0 { + return self.c.ErrorMsg(self.c.Tr.NoItemSelected) + } + + return callback(items, startIdx, endIdx) + } +} + // Like withItem, but doesn't show an error message if no item is selected. // Use this for click actions (it's a no-op to click empty space) func (self *ListControllerTrait[T]) withItemGraceful(callback func(T) error) func() error { return func() error { var zeroValue T - commit := self.getSelected() + commit := self.getSelectedItem() if commit == zeroValue { return nil } diff --git a/pkg/gui/controllers/menu_controller.go b/pkg/gui/controllers/menu_controller.go index 133840cef..a64189138 100644 --- a/pkg/gui/controllers/menu_controller.go +++ b/pkg/gui/controllers/menu_controller.go @@ -22,6 +22,7 @@ func NewMenuController( c, c.Contexts().Menu, c.Contexts().Menu.GetSelected, + c.Contexts().Menu.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/reflog_commits_controller.go b/pkg/gui/controllers/reflog_commits_controller.go index 6e0228784..d9ca3fd02 100644 --- a/pkg/gui/controllers/reflog_commits_controller.go +++ b/pkg/gui/controllers/reflog_commits_controller.go @@ -23,6 +23,7 @@ func NewReflogCommitsController( c, c.Contexts().ReflogCommits, c.Contexts().ReflogCommits.GetSelected, + c.Contexts().ReflogCommits.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/remote_branches_controller.go b/pkg/gui/controllers/remote_branches_controller.go index 25797003b..9d9959448 100644 --- a/pkg/gui/controllers/remote_branches_controller.go +++ b/pkg/gui/controllers/remote_branches_controller.go @@ -26,6 +26,7 @@ func NewRemoteBranchesController( c, c.Contexts().RemoteBranches, c.Contexts().RemoteBranches.GetSelected, + c.Contexts().RemoteBranches.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/remotes_controller.go b/pkg/gui/controllers/remotes_controller.go index ebd232935..c0ee75022 100644 --- a/pkg/gui/controllers/remotes_controller.go +++ b/pkg/gui/controllers/remotes_controller.go @@ -32,6 +32,7 @@ func NewRemotesController( c, c.Contexts().Remotes, c.Contexts().Remotes.GetSelected, + c.Contexts().Remotes.GetSelectedItems, ), c: c, setRemoteBranches: setRemoteBranches, diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index ddef24283..6a413addd 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -24,6 +24,7 @@ func NewStashController( c, c.Contexts().Stash, c.Contexts().Stash.GetSelected, + c.Contexts().Stash.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/sub_commits_controller.go b/pkg/gui/controllers/sub_commits_controller.go index a4ebfb5cd..0acd1f1c4 100644 --- a/pkg/gui/controllers/sub_commits_controller.go +++ b/pkg/gui/controllers/sub_commits_controller.go @@ -24,6 +24,7 @@ func NewSubCommitsController( c, c.Contexts().SubCommits, c.Contexts().SubCommits.GetSelected, + c.Contexts().SubCommits.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/submodules_controller.go b/pkg/gui/controllers/submodules_controller.go index dc43ff35e..dc3952ea0 100644 --- a/pkg/gui/controllers/submodules_controller.go +++ b/pkg/gui/controllers/submodules_controller.go @@ -29,6 +29,7 @@ func NewSubmodulesController( c, c.Contexts().Submodules, c.Contexts().Submodules.GetSelected, + c.Contexts().Submodules.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/suggestions_controller.go b/pkg/gui/controllers/suggestions_controller.go index dbb2b9812..d6e6151ff 100644 --- a/pkg/gui/controllers/suggestions_controller.go +++ b/pkg/gui/controllers/suggestions_controller.go @@ -22,6 +22,7 @@ func NewSuggestionsController( c, c.Contexts().Suggestions, c.Contexts().Suggestions.GetSelected, + c.Contexts().Suggestions.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/switch_to_diff_files_controller.go b/pkg/gui/controllers/switch_to_diff_files_controller.go index 069726147..5207aeaf5 100644 --- a/pkg/gui/controllers/switch_to_diff_files_controller.go +++ b/pkg/gui/controllers/switch_to_diff_files_controller.go @@ -36,6 +36,9 @@ func NewSwitchToDiffFilesController( c, context, context.GetSelectedRef, + func() ([]types.Ref, int, int) { + panic("Not implemented") + }, ), c: c, context: context, diff --git a/pkg/gui/controllers/switch_to_sub_commits_controller.go b/pkg/gui/controllers/switch_to_sub_commits_controller.go index d7bb0a97d..70fe573d7 100644 --- a/pkg/gui/controllers/switch_to_sub_commits_controller.go +++ b/pkg/gui/controllers/switch_to_sub_commits_controller.go @@ -32,6 +32,9 @@ func NewSwitchToSubCommitsController( c, context, context.GetSelectedRef, + func() ([]types.Ref, int, int) { + panic("Not implemented") + }, ), c: c, context: context, diff --git a/pkg/gui/controllers/tags_controller.go b/pkg/gui/controllers/tags_controller.go index 7baebc54c..31fa5ccc0 100644 --- a/pkg/gui/controllers/tags_controller.go +++ b/pkg/gui/controllers/tags_controller.go @@ -25,6 +25,7 @@ func NewTagsController( c, c.Contexts().Tags, c.Contexts().Tags.GetSelected, + c.Contexts().Tags.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/controllers/worktree_options_controller.go b/pkg/gui/controllers/worktree_options_controller.go index 01cc9b362..e158be3b1 100644 --- a/pkg/gui/controllers/worktree_options_controller.go +++ b/pkg/gui/controllers/worktree_options_controller.go @@ -26,6 +26,7 @@ func NewWorktreeOptionsController(c *ControllerCommon, context CanViewWorktreeOp c, context, context.GetSelectedItemId, + context.GetSelectedItemIds, ), c: c, context: context, diff --git a/pkg/gui/controllers/worktrees_controller.go b/pkg/gui/controllers/worktrees_controller.go index b634d0607..5bbde1770 100644 --- a/pkg/gui/controllers/worktrees_controller.go +++ b/pkg/gui/controllers/worktrees_controller.go @@ -28,6 +28,7 @@ func NewWorktreesController( c, c.Contexts().Worktrees, c.Contexts().Worktrees.GetSelected, + c.Contexts().Worktrees.GetSelectedItems, ), c: c, } diff --git a/pkg/gui/filetree/commit_file_tree_view_model.go b/pkg/gui/filetree/commit_file_tree_view_model.go index d7bc447a1..f5cba0edd 100644 --- a/pkg/gui/filetree/commit_file_tree_view_model.go +++ b/pkg/gui/filetree/commit_file_tree_view_model.go @@ -6,6 +6,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" + "github.com/samber/lo" "github.com/sirupsen/logrus" ) @@ -69,6 +70,29 @@ func (self *CommitFileTreeViewModel) GetSelected() *CommitFileNode { return self.Get(self.GetSelectedLineIdx()) } +func (self *CommitFileTreeViewModel) GetSelectedItemId() string { + item := self.GetSelected() + if item == nil { + return "" + } + + return item.ID() +} + +func (self *CommitFileTreeViewModel) GetSelectedItems() ([]*CommitFileNode, int, int) { + panic("Not implemented") +} + +func (self *CommitFileTreeViewModel) GetSelectedItemIds() ([]string, int, int) { + selectedItems, startIdx, endIdx := self.GetSelectedItems() + + ids := lo.Map(selectedItems, func(item *CommitFileNode, _ int) string { + return item.ID() + }) + + return ids, startIdx, endIdx +} + func (self *CommitFileTreeViewModel) GetSelectedFile() *models.CommitFile { node := self.GetSelected() if node == nil { diff --git a/pkg/gui/filetree/file_tree_view_model.go b/pkg/gui/filetree/file_tree_view_model.go index 2364087d3..05cc9cb89 100644 --- a/pkg/gui/filetree/file_tree_view_model.go +++ b/pkg/gui/filetree/file_tree_view_model.go @@ -7,6 +7,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/gui/context/traits" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/jesseduffield/lazygit/pkg/utils" + "github.com/samber/lo" "github.com/sirupsen/logrus" ) @@ -43,6 +44,36 @@ func (self *FileTreeViewModel) GetSelected() *FileNode { return self.Get(self.GetSelectedLineIdx()) } +func (self *FileTreeViewModel) GetSelectedItemId() string { + item := self.GetSelected() + if item == nil { + return "" + } + + return item.ID() +} + +func (self *FileTreeViewModel) GetSelectedItems() ([]*FileNode, int, int) { + startIdx, endIdx := self.GetSelectionRange() + + nodes := []*FileNode{} + for i := startIdx; i <= endIdx; i++ { + nodes = append(nodes, self.Get(i)) + } + + return nodes, startIdx, endIdx +} + +func (self *FileTreeViewModel) GetSelectedItemIds() ([]string, int, int) { + selectedItems, startIdx, endIdx := self.GetSelectedItems() + + ids := lo.Map(selectedItems, func(item *FileNode, _ int) string { + return item.ID() + }) + + return ids, startIdx, endIdx +} + func (self *FileTreeViewModel) GetSelectedFile() *models.File { node := self.GetSelected() if node == nil { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 9053e43f9..86bf63548 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -242,6 +242,12 @@ type MenuItem struct { Section *MenuSection } +// Defining this for the sake of conforming to the HasID interface, which is used +// in list contexts. +func (self *MenuItem) ID() string { + return self.Label +} + type Model struct { CommitFiles []*models.CommitFile Files []*models.File diff --git a/pkg/gui/types/context.go b/pkg/gui/types/context.go index 860a49588..92b07a729 100644 --- a/pkg/gui/types/context.go +++ b/pkg/gui/types/context.go @@ -136,6 +136,7 @@ type IListContext interface { Context GetSelectedItemId() string + GetSelectedItemIds() ([]string, int, int) IsItemVisible(item HasUrn) bool GetList() IList diff --git a/pkg/gui/types/suggestion.go b/pkg/gui/types/suggestion.go index ed8b6ef44..1d4516932 100644 --- a/pkg/gui/types/suggestion.go +++ b/pkg/gui/types/suggestion.go @@ -6,3 +6,8 @@ type Suggestion struct { // label is what is actually displayed so it can e.g. contain color Label string } + +// Conforming to the HasID interface, which is needed for list contexts +func (self *Suggestion) ID() string { + return self.Value +} From 44e2542e4a4772ba7aef210908cd510e5d75657b Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Tue, 23 Jan 2024 13:42:37 +1100 Subject: [PATCH 2/4] Better assertion logic for line selection Previously if we marked a line with IsSelected() we would check if it was selected, but we would not check if other lines were unexpectedly selected. Now, if you use IsSelected(), we ensure that _only_ the lines you marked as such are the selected lines. --- pkg/integration/components/text_matcher.go | 6 ++- pkg/integration/components/view_driver.go | 60 ++++++++++++++++++---- pkg/integration/tests/test_list.go | 1 + 3 files changed, 56 insertions(+), 11 deletions(-) diff --git a/pkg/integration/components/text_matcher.go b/pkg/integration/components/text_matcher.go index a5b987646..8d8077922 100644 --- a/pkg/integration/components/text_matcher.go +++ b/pkg/integration/components/text_matcher.go @@ -9,6 +9,8 @@ import ( ) type TextMatcher struct { + // If you add or change a field here, be sure to update the copy + // code in checkIsSelected() *Matcher[string] } @@ -95,8 +97,8 @@ func (self *TextMatcher) IsSelected() *TextMatcher { // if the matcher has an `IsSelected` rule, it returns true, along with the matcher after that rule has been removed func (self *TextMatcher) checkIsSelected() (bool, *TextMatcher) { // copying into a new matcher in case we want to re-use the original later - newMatcher := &TextMatcher{} - *newMatcher = *self + newMatcher := &TextMatcher{Matcher: &Matcher[string]{}} + *newMatcher.Matcher = *self.Matcher check := lo.ContainsBy(newMatcher.rules, func(rule matcherRule[string]) bool { return rule.name == IS_SELECTED_RULE_NAME }) diff --git a/pkg/integration/components/view_driver.go b/pkg/integration/components/view_driver.go index 437e647be..6160d6b2e 100644 --- a/pkg/integration/components/view_driver.go +++ b/pkg/integration/components/view_driver.go @@ -211,29 +211,63 @@ func (self *ViewDriver) validateVisibleLineCount(matchers []*TextMatcher) { func (self *ViewDriver) assertLines(offset int, matchers ...*TextMatcher) *ViewDriver { view := self.getView() + var expectedStartIdx, expectedEndIdx int + foundSelectionStart := false + foundSelectionEnd := false + expectedSelectedLines := []string{} + for matcherIndex, matcher := range matchers { lineIdx := matcherIndex + offset + checkIsSelected, matcher := matcher.checkIsSelected() + if checkIsSelected { + if foundSelectionEnd { + self.t.fail("The IsSelected matcher can only be used on a contiguous range of lines.") + } + if !foundSelectionStart { + expectedStartIdx = lineIdx + foundSelectionStart = true + } + expectedSelectedLines = append(expectedSelectedLines, matcher.name()) + expectedEndIdx = lineIdx + } else if foundSelectionStart { + foundSelectionEnd = true + } + } + + for matcherIndex, matcher := range matchers { + lineIdx := matcherIndex + offset + expectSelected, matcher := matcher.checkIsSelected() + self.t.matchString(matcher, fmt.Sprintf("Unexpected content in view '%s'.", view.Name()), func() string { return view.BufferLines()[lineIdx] }, ) - if checkIsSelected { + // If any of the matchers care about the selection, we need to + // assert on the selection for each matcher. + if foundSelectionStart { self.t.assertWithRetries(func() (bool, string) { startIdx, endIdx := self.getSelectedRange() - if lineIdx < startIdx || lineIdx > endIdx { - if startIdx == endIdx { - return false, fmt.Sprintf("Unexpected selected line index in view '%s'. Expected %d, got %d", view.Name(), lineIdx, startIdx) - } else { - lines := self.getSelectedLines() - return false, fmt.Sprintf("Unexpected selected line index in view '%s'. Expected line %d to be in range %d to %d. Selected lines:\n---\n%s\n---\n\nExpected line: '%s'", view.Name(), lineIdx, startIdx, endIdx, strings.Join(lines, "\n"), matcher.name()) - } + selected := lineIdx >= startIdx && lineIdx <= endIdx + + if (selected && expectSelected) || (!selected && !expectSelected) { + return true, "" } - return true, "" + + lines := self.getSelectedLines() + + return false, fmt.Sprintf( + "Unexpected selection in view '%s'. Expected %s to be selected but got %s.\nExpected selected lines:\n---\n%s\n---\n\nActual selected lines:\n---\n%s\n---\n", + view.Name(), + formatLineRange(startIdx, endIdx), + formatLineRange(expectedStartIdx, expectedEndIdx), + strings.Join(lines, "\n"), + strings.Join(expectedSelectedLines, "\n"), + ) }) } } @@ -241,6 +275,14 @@ func (self *ViewDriver) assertLines(offset int, matchers ...*TextMatcher) *ViewD return self } +func formatLineRange(from int, to int) string { + if from == to { + return "line " + fmt.Sprintf("%d", from) + } + + return "lines " + fmt.Sprintf("%d-%d", from, to) +} + // asserts on the content of the view i.e. the stuff within the view's frame. func (self *ViewDriver) Content(matcher *TextMatcher) *ViewDriver { self.t.matchString(matcher, fmt.Sprintf("%s: Unexpected content.", self.context), diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 3e34549af..7f5cc23fa 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -161,6 +161,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.EditTheConflCommit, interactive_rebase.FixupFirstCommit, interactive_rebase.FixupSecondCommit, + interactive_rebase.MidRebaseRangeSelect, interactive_rebase.Move, interactive_rebase.MoveInRebase, interactive_rebase.MoveWithCustomCommentChar, From f0de8801368c388b0065008a769b6cfc2ff5205e Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Mon, 8 Jan 2024 11:49:42 +1100 Subject: [PATCH 3/4] Support range select in rebase actions --- pkg/app/daemon/daemon.go | 74 +-- pkg/commands/git_commands/rebase.go | 94 ++-- .../helpers/merge_and_rebase_helper.go | 15 + .../controllers/local_commits_controller.go | 443 ++++++++---------- pkg/i18n/chinese.go | 4 +- pkg/i18n/dutch.go | 4 +- pkg/i18n/english.go | 22 +- pkg/i18n/japanese.go | 4 +- pkg/i18n/korean.go | 4 +- pkg/i18n/polish.go | 4 +- pkg/i18n/russian.go | 4 +- pkg/i18n/traditional_chinese.go | 4 +- pkg/integration/tests/demo/undo.go | 4 +- .../drop_with_custom_comment_char.go | 4 +- .../edit_non_todo_commit_during_rebase.go | 2 +- .../edit_the_confl_commit.go | 2 +- .../interactive_rebase/fixup_second_commit.go | 2 +- .../mid_rebase_range_select.go | 205 ++++++++ .../tests/interactive_rebase/move.go | 6 + .../interactive_rebase/move_in_rebase.go | 8 +- .../outside_rebase_range_select.go | 155 ++++++ .../squash_down_second_commit.go | 2 +- pkg/integration/tests/test_list.go | 1 + .../tests/undo/undo_checkout_and_drop.go | 4 +- pkg/integration/tests/undo/undo_drop.go | 4 +- pkg/utils/rebase_todo.go | 67 ++- 26 files changed, 776 insertions(+), 366 deletions(-) create mode 100644 pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go create mode 100644 pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go index 95fa6bc9e..70490aef0 100644 --- a/pkg/app/daemon/daemon.go +++ b/pkg/app/daemon/daemon.go @@ -34,8 +34,8 @@ const ( DaemonKindExitImmediately DaemonKindCherryPick - DaemonKindMoveTodoUp - DaemonKindMoveTodoDown + DaemonKindMoveTodosUp + DaemonKindMoveTodosDown DaemonKindInsertBreak DaemonKindChangeTodoActions DaemonKindMoveFixupCommitDown @@ -56,8 +56,8 @@ func getInstruction() Instruction { DaemonKindCherryPick: deserializeInstruction[*CherryPickCommitsInstruction], DaemonKindChangeTodoActions: deserializeInstruction[*ChangeTodoActionsInstruction], DaemonKindMoveFixupCommitDown: deserializeInstruction[*MoveFixupCommitDownInstruction], - DaemonKindMoveTodoUp: deserializeInstruction[*MoveTodoUpInstruction], - DaemonKindMoveTodoDown: deserializeInstruction[*MoveTodoDownInstruction], + DaemonKindMoveTodosUp: deserializeInstruction[*MoveTodosUpInstruction], + DaemonKindMoveTodosDown: deserializeInstruction[*MoveTodosDownInstruction], DaemonKindInsertBreak: deserializeInstruction[*InsertBreakInstruction], } @@ -208,13 +208,15 @@ func (self *ChangeTodoActionsInstruction) SerializedInstructions() string { func (self *ChangeTodoActionsInstruction) run(common *common.Common) error { return handleInteractiveRebase(common, func(path string) error { - for _, c := range self.Changes { - if err := utils.EditRebaseTodo(path, c.Sha, todo.Pick, c.NewAction, getCommentChar()); err != nil { - return err + changes := lo.Map(self.Changes, func(c ChangeTodoAction, _ int) utils.TodoChange { + return utils.TodoChange{ + Sha: c.Sha, + OldAction: todo.Pick, + NewAction: c.NewAction, } - } + }) - return nil + return utils.EditRebaseTodo(path, changes, getCommentChar()) }) } @@ -247,51 +249,65 @@ func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error { }) } -type MoveTodoUpInstruction struct { - Sha string +type MoveTodosUpInstruction struct { + Shas []string } -func NewMoveTodoUpInstruction(sha string) Instruction { - return &MoveTodoUpInstruction{ - Sha: sha, +func NewMoveTodosUpInstruction(shas []string) Instruction { + return &MoveTodosUpInstruction{ + Shas: shas, } } -func (self *MoveTodoUpInstruction) Kind() DaemonKind { - return DaemonKindMoveTodoUp +func (self *MoveTodosUpInstruction) Kind() DaemonKind { + return DaemonKindMoveTodosUp } -func (self *MoveTodoUpInstruction) SerializedInstructions() string { +func (self *MoveTodosUpInstruction) SerializedInstructions() string { return serializeInstruction(self) } -func (self *MoveTodoUpInstruction) run(common *common.Common) error { +func (self *MoveTodosUpInstruction) run(common *common.Common) error { + todosToMove := lo.Map(self.Shas, func(sha string, _ int) utils.Todo { + return utils.Todo{ + Sha: sha, + Action: todo.Pick, + } + }) + return handleInteractiveRebase(common, func(path string) error { - return utils.MoveTodoUp(path, self.Sha, todo.Pick, getCommentChar()) + return utils.MoveTodosUp(path, todosToMove, getCommentChar()) }) } -type MoveTodoDownInstruction struct { - Sha string +type MoveTodosDownInstruction struct { + Shas []string } -func NewMoveTodoDownInstruction(sha string) Instruction { - return &MoveTodoDownInstruction{ - Sha: sha, +func NewMoveTodosDownInstruction(shas []string) Instruction { + return &MoveTodosDownInstruction{ + Shas: shas, } } -func (self *MoveTodoDownInstruction) Kind() DaemonKind { - return DaemonKindMoveTodoDown +func (self *MoveTodosDownInstruction) Kind() DaemonKind { + return DaemonKindMoveTodosDown } -func (self *MoveTodoDownInstruction) SerializedInstructions() string { +func (self *MoveTodosDownInstruction) SerializedInstructions() string { return serializeInstruction(self) } -func (self *MoveTodoDownInstruction) run(common *common.Common) error { +func (self *MoveTodosDownInstruction) run(common *common.Common) error { + todosToMove := lo.Map(self.Shas, func(sha string, _ int) utils.Todo { + return utils.Todo{ + Sha: sha, + Action: todo.Pick, + } + }) + return handleInteractiveRebase(common, func(path string) error { - return utils.MoveTodoDown(path, self.Sha, todo.Pick, getCommentChar()) + return utils.MoveTodosDown(path, todosToMove, getCommentChar()) }) } diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index fde049cda..03d4030ac 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -105,58 +105,49 @@ func (self *RebaseCommands) GenericAmend(commits []*models.Commit, index int, f return self.ContinueRebase() } -func (self *RebaseCommands) MoveCommitDown(commits []*models.Commit, index int) error { - baseShaOrRoot := getBaseShaOrRoot(commits, index+2) +func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error { + baseShaOrRoot := getBaseShaOrRoot(commits, endIdx+2) - sha := commits[index].Sha - - msg := utils.ResolvePlaceholderString( - self.Tr.Log.MoveCommitDown, - map[string]string{ - "shortSha": utils.ShortSha(sha), - }, - ) - self.os.LogCommand(msg, false) + shas := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string { + return commit.Sha + }) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseShaOrRoot: baseShaOrRoot, - instruction: daemon.NewMoveTodoDownInstruction(sha), + instruction: daemon.NewMoveTodosDownInstruction(shas), overrideEditor: true, }).Run() } -func (self *RebaseCommands) MoveCommitUp(commits []*models.Commit, index int) error { - baseShaOrRoot := getBaseShaOrRoot(commits, index+1) +func (self *RebaseCommands) MoveCommitsUp(commits []*models.Commit, startIdx int, endIdx int) error { + baseShaOrRoot := getBaseShaOrRoot(commits, endIdx+1) - sha := commits[index].Sha - - msg := utils.ResolvePlaceholderString( - self.Tr.Log.MoveCommitUp, - map[string]string{ - "shortSha": utils.ShortSha(sha), - }, - ) - self.os.LogCommand(msg, false) + shas := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string { + return commit.Sha + }) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ baseShaOrRoot: baseShaOrRoot, - instruction: daemon.NewMoveTodoUpInstruction(sha), + instruction: daemon.NewMoveTodosUpInstruction(shas), overrideEditor: true, }).Run() } -func (self *RebaseCommands) InteractiveRebase(commits []*models.Commit, index int, action todo.TodoCommand) error { - baseIndex := index + 1 +func (self *RebaseCommands) InteractiveRebase(commits []*models.Commit, startIdx int, endIdx int, action todo.TodoCommand) error { + baseIndex := endIdx + 1 if action == todo.Squash || action == todo.Fixup { baseIndex++ } baseShaOrRoot := getBaseShaOrRoot(commits, baseIndex) - changes := []daemon.ChangeTodoAction{{ - Sha: commits[index].Sha, - NewAction: action, - }} + changes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) daemon.ChangeTodoAction { + return daemon.ChangeTodoAction{ + Sha: commit.Sha, + NewAction: action, + } + }) + self.os.LogCommand(logTodoChanges(changes), false) return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{ @@ -200,7 +191,7 @@ func logTodoChanges(changes []daemon.ChangeTodoAction) string { changeTodoStr := strings.Join(lo.Map(changes, func(c daemon.ChangeTodoAction, _ int) string { return fmt.Sprintf("%s:%s", c.Sha, c.NewAction) }), "\n") - return fmt.Sprintf("Changing TODO actions: %s", changeTodoStr) + return fmt.Sprintf("Changing TODO actions:\n%s", changeTodoStr) } type PrepareInteractiveRebaseCommandOpts struct { @@ -281,22 +272,45 @@ func (self *RebaseCommands) AmendTo(commits []*models.Commit, commitIndex int) e }).Run() } -// EditRebaseTodo sets the action for a given rebase commit in the git-rebase-todo file -func (self *RebaseCommands) EditRebaseTodo(commit *models.Commit, action todo.TodoCommand) error { +// Sets the action for the given commits in the git-rebase-todo file +func (self *RebaseCommands) EditRebaseTodo(commits []*models.Commit, action todo.TodoCommand) error { + commitsWithAction := lo.Map(commits, func(commit *models.Commit, _ int) utils.TodoChange { + return utils.TodoChange{ + Sha: commit.Sha, + OldAction: commit.Action, + NewAction: action, + } + }) + return utils.EditRebaseTodo( - filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"), commit.Sha, commit.Action, action, self.config.GetCoreCommentChar()) + filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"), + commitsWithAction, + self.config.GetCoreCommentChar(), + ) } -// MoveTodoDown moves a rebase todo item down by one position -func (self *RebaseCommands) MoveTodoDown(commit *models.Commit) error { +func (self *RebaseCommands) MoveTodosDown(commits []*models.Commit) error { fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo") - return utils.MoveTodoDown(fileName, commit.Sha, commit.Action, self.config.GetCoreCommentChar()) + todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo { + return utils.Todo{ + Sha: commit.Sha, + Action: commit.Action, + } + }) + + return utils.MoveTodosDown(fileName, todosToMove, self.config.GetCoreCommentChar()) } -// MoveTodoDown moves a rebase todo item down by one position -func (self *RebaseCommands) MoveTodoUp(commit *models.Commit) error { +func (self *RebaseCommands) MoveTodosUp(commits []*models.Commit) error { fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo") - return utils.MoveTodoUp(fileName, commit.Sha, commit.Action, self.config.GetCoreCommentChar()) + todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo { + return utils.Todo{ + Sha: commit.Sha, + Action: commit.Action, + } + }) + + return utils.MoveTodosUp(fileName, todosToMove, self.config.GetCoreCommentChar()) } // SquashAllAboveFixupCommits squashes all fixup! commits above the given one diff --git a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go index 2b94dfa7a..1fe6738e3 100644 --- a/pkg/gui/controllers/helpers/merge_and_rebase_helper.go +++ b/pkg/gui/controllers/helpers/merge_and_rebase_helper.go @@ -2,6 +2,8 @@ package helpers import ( "fmt" + "os" + "path/filepath" "strings" "github.com/jesseduffield/gocui" @@ -80,6 +82,19 @@ func (self *MergeAndRebaseHelper) genericMergeCommand(command string) error { } self.c.LogAction(fmt.Sprintf("Merge/Rebase: %s", command)) + if status == enums.REBASE_MODE_REBASING { + todoFile, err := os.ReadFile( + filepath.Join(self.c.Git().RepoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo"), + ) + + if err != nil { + if !os.IsNotExist(err) { + return err + } + } else { + self.c.LogCommand(string(todoFile), false) + } + } commandType := "" switch status { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 5fe08b85e..e1d20b554 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -45,6 +45,7 @@ func NewLocalCommitsController( c, c.Contexts().LocalCommits, c.Contexts().LocalCommits.GetSelected, + c.Contexts().LocalCommits.GetSelectedItems, ), } } @@ -55,17 +56,23 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ outsideFilterModeBindings := []*types.Binding{ { Key: opts.GetKey(opts.Config.Commits.SquashDown), - Handler: self.withItem(self.squashDown), + Handler: self.withItemsRange(self.squashDown), GetDisabledReason: self.require( - self.singleItemSelected(self.getDisabledReasonForSquashDown), + self.itemRangeSelected( + self.midRebaseCommandEnabled, + self.canSquashOrFixup, + ), ), Description: self.c.Tr.SquashDown, }, { Key: opts.GetKey(opts.Config.Commits.MarkCommitAsFixup), - Handler: self.withItem(self.fixup), + Handler: self.withItemsRange(self.fixup), GetDisabledReason: self.require( - self.singleItemSelected(self.getDisabledReasonForFixup), + self.itemRangeSelected( + self.midRebaseCommandEnabled, + self.canSquashOrFixup, + ), ), Description: self.c.Tr.FixupCommit, }, @@ -73,7 +80,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Key: opts.GetKey(opts.Config.Commits.RenameCommit), Handler: self.withItem(self.reword), GetDisabledReason: self.require( - self.singleItemSelected(self.rebaseCommandEnabled(todo.Reword)), + self.singleItemSelected(self.rewordEnabled), ), Description: self.c.Tr.RewordCommit, }, @@ -81,23 +88,26 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Key: opts.GetKey(opts.Config.Commits.RenameCommitWithEditor), Handler: self.withItem(self.rewordEditor), GetDisabledReason: self.require( - self.singleItemSelected(self.rebaseCommandEnabled(todo.Reword)), + self.singleItemSelected(self.rewordEnabled), ), Description: self.c.Tr.RenameCommitEditor, }, { Key: opts.GetKey(opts.Config.Universal.Remove), - Handler: self.withItem(self.drop), + Handler: self.withItemsRange(self.drop), GetDisabledReason: self.require( - self.singleItemSelected(self.rebaseCommandEnabled(todo.Drop)), + self.itemRangeSelected( + self.midRebaseCommandEnabled, + ), ), Description: self.c.Tr.DeleteCommit, }, { Key: opts.GetKey(editCommitKey), - Handler: self.withItem(self.edit), + Handler: self.withItems(self.edit), + // TODO: have disabled reason ensure that if we're not rebasing, we only select one commit GetDisabledReason: self.require( - self.singleItemSelected(self.rebaseCommandEnabled(todo.Edit)), + self.itemRangeSelected(self.midRebaseCommandEnabled), ), Description: self.c.Tr.EditCommit, }, @@ -107,7 +117,7 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ // when you manually select the base commit. Key: opts.GetKey(opts.Config.Commits.StartInteractiveRebase), Handler: self.withItem(self.quickStartInteractiveRebase), - GetDisabledReason: self.require(self.notMidRebase, self.canFindCommitForQuickStart), + GetDisabledReason: self.require(self.notMidRebase(self.c.Tr.AlreadyRebasing), self.canFindCommitForQuickStart), Description: self.c.Tr.QuickStartInteractiveRebase, Tooltip: utils.ResolvePlaceholderString(self.c.Tr.QuickStartInteractiveRebaseTooltip, map[string]string{ "editKey": keybindings.Label(editCommitKey), @@ -115,9 +125,9 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ }, { Key: opts.GetKey(opts.Config.Commits.PickCommit), - Handler: self.withItem(self.pick), + Handler: self.withItems(self.pick), GetDisabledReason: self.require( - self.singleItemSelected(self.rebaseCommandEnabled(todo.Pick)), + self.itemRangeSelected(self.pickEnabled), ), Description: self.c.Tr.PickCommit, }, @@ -131,22 +141,28 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ Key: opts.GetKey(opts.Config.Commits.SquashAboveCommits), Handler: self.withItem(self.squashAllAboveFixupCommits), GetDisabledReason: self.require( - self.notMidRebase, + self.notMidRebase(self.c.Tr.AlreadyRebasing), self.singleItemSelected(), ), Description: self.c.Tr.SquashAboveCommits, }, { - Key: opts.GetKey(opts.Config.Commits.MoveDownCommit), - Handler: self.withItem(self.moveDown), - GetDisabledReason: self.require(self.singleItemSelected()), - Description: self.c.Tr.MoveDownCommit, + Key: opts.GetKey(opts.Config.Commits.MoveDownCommit), + Handler: self.withItemsRange(self.moveDown), + GetDisabledReason: self.require(self.itemRangeSelected( + self.midRebaseCommandEnabled, + self.canMoveDown, + )), + Description: self.c.Tr.MoveDownCommit, }, { - Key: opts.GetKey(opts.Config.Commits.MoveUpCommit), - Handler: self.withItem(self.moveUp), - GetDisabledReason: self.require(self.singleItemSelected()), - Description: self.c.Tr.MoveUpCommit, + Key: opts.GetKey(opts.Config.Commits.MoveUpCommit), + Handler: self.withItemsRange(self.moveUp), + GetDisabledReason: self.require(self.itemRangeSelected( + self.midRebaseCommandEnabled, + self.canMoveUp, + )), + Description: self.c.Tr.MoveUpCommit, }, { Key: opts.GetKey(opts.Config.Commits.PasteCommits), @@ -263,13 +279,9 @@ func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts { return nil } -func (self *LocalCommitsController) squashDown(commit *models.Commit) error { - applied, err := self.handleMidRebaseCommand(todo.Squash, commit) - if err != nil { - return err - } - if applied { - return nil +func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + if self.isRebasing() { + return self.updateTodos(todo.Squash, selectedCommits) } return self.c.Confirm(types.ConfirmOpts{ @@ -278,27 +290,15 @@ func (self *LocalCommitsController) squashDown(commit *models.Commit) error { HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.SquashingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) - return self.interactiveRebase(todo.Squash) + return self.interactiveRebase(todo.Squash, startIdx, endIdx) }) }, }) } -func (self *LocalCommitsController) getDisabledReasonForSquashDown(commit *models.Commit) *types.DisabledReason { - if self.context().GetSelectedLineIdx() >= len(self.c.Model().Commits)-1 { - return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit} - } - - return self.rebaseCommandEnabled(todo.Squash)(commit) -} - -func (self *LocalCommitsController) fixup(commit *models.Commit) error { - applied, err := self.handleMidRebaseCommand(todo.Fixup, commit) - if err != nil { - return err - } - if applied { - return nil +func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + if self.isRebasing() { + return self.updateTodos(todo.Fixup, selectedCommits) } return self.c.Confirm(types.ConfirmOpts{ @@ -307,29 +307,13 @@ func (self *LocalCommitsController) fixup(commit *models.Commit) error { HandleConfirm: func() error { return self.c.WithWaitingStatus(self.c.Tr.FixingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) - return self.interactiveRebase(todo.Fixup) + return self.interactiveRebase(todo.Fixup, startIdx, endIdx) }) }, }) } -func (self *LocalCommitsController) getDisabledReasonForFixup(commit *models.Commit) *types.DisabledReason { - if self.context().GetSelectedLineIdx() >= len(self.c.Model().Commits)-1 { - return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit} - } - - return self.rebaseCommandEnabled(todo.Squash)(commit) -} - func (self *LocalCommitsController) reword(commit *models.Commit) error { - applied, err := self.handleMidRebaseCommand(todo.Reword, commit) - if err != nil { - return err - } - if applied { - return nil - } - commitMessage, err := self.c.Git().Commit.GetCommitMessage(commit.Sha) if err != nil { return self.c.Error(err) @@ -404,14 +388,6 @@ func (self *LocalCommitsController) doRewordEditor() error { } func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error { - midRebase, err := self.handleMidRebaseCommand(todo.Reword, commit) - if err != nil { - return err - } - if midRebase { - return nil - } - if self.c.UserConfig.Gui.SkipRewordInEditorWarning { return self.doRewordEditor() } else { @@ -423,37 +399,37 @@ func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error { } } -func (self *LocalCommitsController) drop(commit *models.Commit) error { - applied, err := self.handleMidRebaseCommand(todo.Drop, commit) - if err != nil { - return err - } - if applied { - return nil +func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + if self.isRebasing() { + return self.updateTodos(todo.Drop, selectedCommits) } return self.c.Confirm(types.ConfirmOpts{ - Title: self.c.Tr.DeleteCommitTitle, - Prompt: self.c.Tr.DeleteCommitPrompt, + Title: self.c.Tr.DropCommitTitle, + Prompt: self.c.Tr.DropCommitPrompt, HandleConfirm: func() error { - return self.c.WithWaitingStatus(self.c.Tr.DeletingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatus(self.c.Tr.DroppingStatus, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) - return self.interactiveRebase(todo.Drop) + return self.interactiveRebase(todo.Drop, startIdx, endIdx) }) }, }) } -func (self *LocalCommitsController) edit(commit *models.Commit) error { - applied, err := self.handleMidRebaseCommand(todo.Edit, commit) - if err != nil { - return err - } - if applied { - return nil +func (self *LocalCommitsController) edit(selectedCommits []*models.Commit) error { + if self.isRebasing() { + return self.updateTodos(todo.Edit, selectedCommits) } - return self.startInteractiveRebaseWithEdit(commit, commit) + // TODO: support range select here (start a rebase and set the selected commits + // to 'edit' in the todo file) + if len(selectedCommits) > 1 { + return self.c.ErrorMsg(self.c.Tr.RangeSelectNotSupported) + } + + selectedCommit := selectedCommits[0] + + return self.startInteractiveRebaseWithEdit(selectedCommit, selectedCommit) } func (self *LocalCommitsController) quickStartInteractiveRebase(selectedCommit *models.Commit) error { @@ -504,13 +480,9 @@ func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() ( return commit, nil } -func (self *LocalCommitsController) pick(commit *models.Commit) error { - applied, err := self.handleMidRebaseCommand(todo.Pick, commit) - if err != nil { - return err - } - if applied { - return nil +func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error { + if self.isRebasing() { + return self.updateTodos(todo.Pick, selectedCommits) } // at this point we aren't actually rebasing so we will interpret this as an @@ -518,159 +490,93 @@ func (self *LocalCommitsController) pick(commit *models.Commit) error { return self.pullFiles() } -func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand) error { - err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, self.context().GetSelectedLineIdx(), action) +func (self *LocalCommitsController) interactiveRebase(action todo.TodoCommand, startIdx int, endIdx int) error { + // When performing an action that will remove the selected commits, we need to select the + // next commit down (which will end up at the start index after the action is performed) + if action == todo.Drop || action == todo.Fixup || action == todo.Squash { + self.context().SetSelection(startIdx) + } + + err := self.c.Git().Rebase.InteractiveRebase(self.c.Model().Commits, startIdx, endIdx, action) + return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) } -// handleMidRebaseCommand sees if the selected commit is in fact a rebasing +// updateTodos sees if the selected commit is in fact a rebasing // commit meaning you are trying to edit the todo file rather than actually // begin a rebase. It then updates the todo file with that action -func (self *LocalCommitsController) handleMidRebaseCommand(action todo.TodoCommand, commit *models.Commit) (bool, error) { - if !commit.IsTODO() { - return false, nil +func (self *LocalCommitsController) updateTodos(action todo.TodoCommand, selectedCommits []*models.Commit) error { + if err := self.c.Git().Rebase.EditRebaseTodo(selectedCommits, action); err != nil { + return self.c.Error(err) } - self.c.LogAction("Update rebase TODO") - - msg := utils.ResolvePlaceholderString( - self.c.Tr.Log.HandleMidRebaseCommand, - map[string]string{ - "shortSha": commit.ShortSha(), - "action": action.String(), - }, - ) - self.c.LogCommand(msg, false) - - if err := self.c.Git().Rebase.EditRebaseTodo(commit, action); err != nil { - return false, self.c.Error(err) - } - - return true, self.c.Refresh(types.RefreshOptions{ + return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) } -func (self *LocalCommitsController) rebaseCommandEnabled(action todo.TodoCommand) func(*models.Commit) *types.DisabledReason { - return func(commit *models.Commit) *types.DisabledReason { - if commit.Action == models.ActionConflict { - return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed} - } - - if !commit.IsTODO() { - if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE { - // If we are in a rebase, the only action that is allowed for - // non-todo commits is rewording the current head commit - if !(action == todo.Reword && self.isHeadCommit()) { - return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing} - } - } - - return nil - } - - // for now we do not support setting 'reword' because it requires an editor - // and that means we either unconditionally wait around for the subprocess to ask for - // our input or we set a lazygit client as the EDITOR env variable and have it - // request us to edit the commit message when prompted. - if action == todo.Reword { - return &types.DisabledReason{Text: self.c.Tr.RewordNotSupported} - } - - if allowed := isChangeOfRebaseTodoAllowed(action); !allowed { - return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed} - } - - return nil +func (self *LocalCommitsController) rewordEnabled(commit *models.Commit) *types.DisabledReason { + // for now we do not support setting 'reword' on TODO commits because it requires an editor + // and that means we either unconditionally wait around for the subprocess to ask for + // our input or we set a lazygit client as the EDITOR env variable and have it + // request us to edit the commit message when prompted. + if commit.IsTODO() { + return &types.DisabledReason{Text: self.c.Tr.RewordNotSupported} } + + // If we are in a rebase, the only action that is allowed for + // non-todo commits is rewording the current head commit + if self.isRebasing() && !self.isHeadCommit() { + return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing} + } + + return nil } -func (self *LocalCommitsController) moveDown(commit *models.Commit) error { - index := self.context().GetSelectedLineIdx() - commits := self.c.Model().Commits +func (self *LocalCommitsController) isRebasing() bool { + return self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE +} - // can't move past the initial commit - if index >= len(commits)-1 { - return nil - } - - if commit.IsTODO() { - if !commits[index+1].IsTODO() || commits[index+1].Action == models.ActionConflict { - return nil - } - - // logging directly here because MoveTodoDown doesn't have enough information - // to provide a useful log - self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - - msg := utils.ResolvePlaceholderString( - self.c.Tr.Log.MovingCommitDown, - map[string]string{ - "shortSha": commit.ShortSha(), - }, - ) - self.c.LogCommand(msg, false) - - if err := self.c.Git().Rebase.MoveTodoDown(commit); err != nil { +func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + if self.isRebasing() { + if err := self.c.Git().Rebase.MoveTodosDown(selectedCommits); err != nil { return self.c.Error(err) } - self.context().MoveSelectedLine(1) + self.context().MoveSelection(1) + return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) } - if self.c.Git().Status.WorkingTreeState() != enums.REBASE_MODE_NONE { - return self.c.ErrorMsg(self.c.Tr.AlreadyRebasing) - } - return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - err := self.c.Git().Rebase.MoveCommitDown(self.c.Model().Commits, index) + err := self.c.Git().Rebase.MoveCommitsDown(self.c.Model().Commits, startIdx, endIdx) if err == nil { - self.context().MoveSelectedLine(1) + self.context().MoveSelection(1) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{Mode: types.SYNC}) }) } -func (self *LocalCommitsController) moveUp(commit *models.Commit) error { - index := self.context().GetSelectedLineIdx() - if index == 0 { - return nil - } - - if commit.IsTODO() { - // logging directly here because MoveTodoDown doesn't have enough information - // to provide a useful log - self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - msg := utils.ResolvePlaceholderString( - self.c.Tr.Log.MovingCommitUp, - map[string]string{ - "shortSha": commit.ShortSha(), - }, - ) - self.c.LogCommand(msg, false) - - if err := self.c.Git().Rebase.MoveTodoUp(self.c.Model().Commits[index]); err != nil { +func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + if self.isRebasing() { + if err := self.c.Git().Rebase.MoveTodosUp(selectedCommits); err != nil { return self.c.Error(err) } - self.context().MoveSelectedLine(-1) + self.context().MoveSelection(-1) + return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.REBASE_COMMITS}, }) } - if self.c.Git().Status.WorkingTreeState() != enums.REBASE_MODE_NONE { - return self.c.ErrorMsg(self.c.Tr.AlreadyRebasing) - } - return self.c.WithWaitingStatusSync(self.c.Tr.MovingStatus, func() error { self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err := self.c.Git().Rebase.MoveCommitUp(self.c.Model().Commits, index) + err := self.c.Git().Rebase.MoveCommitsUp(self.c.Model().Commits, startIdx, endIdx) if err == nil { - self.context().MoveSelectedLine(-1) + self.context().MoveSelection(-1) } return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{Mode: types.SYNC}) @@ -693,10 +599,6 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { }) } - if self.c.Git().Status.WorkingTreeState() != enums.REBASE_MODE_NONE { - return self.c.ErrorMsg(self.c.Tr.AlreadyRebasing) - } - return self.c.Confirm(types.ConfirmOpts{ Title: self.c.Tr.AmendCommitTitle, Prompt: self.c.Tr.AmendCommitPrompt, @@ -713,7 +615,7 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { } func (self *LocalCommitsController) canAmend(commit *models.Commit) *types.DisabledReason { - if !self.isHeadCommit() && self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE { + if !self.isHeadCommit() && self.isRebasing() { return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing} } @@ -721,10 +623,6 @@ func (self *LocalCommitsController) canAmend(commit *models.Commit) *types.Disab } func (self *LocalCommitsController) amendAttribute(commit *models.Commit) error { - if self.c.Git().Status.WorkingTreeState() != enums.REBASE_MODE_NONE && !self.isHeadCommit() { - return self.c.ErrorMsg(self.c.Tr.AlreadyRebasing) - } - return self.c.Menu(types.CreateMenuOptions{ Title: "Amend commit attribute", Items: []*types.MenuItem{ @@ -846,7 +744,7 @@ func (self *LocalCommitsController) createRevertMergeCommitMenu(commit *models.C } func (self *LocalCommitsController) afterRevertCommit() error { - self.context().MoveSelectedLine(1) + self.context().MoveSelection(1) return self.c.Refresh(types.RefreshOptions{ Mode: types.SYNC, Scope: []types.RefreshableView{types.COMMITS, types.BRANCHES}, }) @@ -895,24 +793,6 @@ func (self *LocalCommitsController) squashAllAboveFixupCommits(commit *models.Co }) } -// For getting disabled reason -func (self *LocalCommitsController) notMidRebase() *types.DisabledReason { - if self.c.Model().WorkingTreeStateAtLastCommitRefresh != enums.REBASE_MODE_NONE { - return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing} - } - - return nil -} - -// For getting disabled reason -func (self *LocalCommitsController) canFindCommitForQuickStart() *types.DisabledReason { - if _, err := self.findCommitForQuickStartInteractiveRebase(); err != nil { - return &types.DisabledReason{Text: err.Error(), ShowErrorInPanel: true} - } - - return nil -} - func (self *LocalCommitsController) createTag(commit *models.Commit) error { return self.c.Helpers().Tags.OpenCreateTagPrompt(commit.Sha, func() {}) } @@ -1079,15 +959,88 @@ func (self *LocalCommitsController) isHeadCommit() bool { return models.IsHeadCommit(self.c.Model().Commits, self.context().GetSelectedLineIdx()) } -func isChangeOfRebaseTodoAllowed(action todo.TodoCommand) bool { - allowedActions := []todo.TodoCommand{ - todo.Pick, - todo.Drop, - todo.Edit, - todo.Fixup, - todo.Squash, - todo.Reword, +func (self *LocalCommitsController) notMidRebase(message string) func() *types.DisabledReason { + return func() *types.DisabledReason { + if self.isRebasing() { + return &types.DisabledReason{Text: message} + } + + return nil + } +} + +func (self *LocalCommitsController) canFindCommitForQuickStart() *types.DisabledReason { + if _, err := self.findCommitForQuickStartInteractiveRebase(); err != nil { + return &types.DisabledReason{Text: err.Error(), ShowErrorInPanel: true} } - return lo.Contains(allowedActions, action) + return nil +} + +func (self *LocalCommitsController) canSquashOrFixup(_selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { + if endIdx >= len(self.c.Model().Commits)-1 { + return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupFirstCommit} + } + + return nil +} + +func (self *LocalCommitsController) canMoveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { + if endIdx >= len(self.c.Model().Commits)-1 { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} + } + + if self.isRebasing() { + commits := self.c.Model().Commits + + if !commits[endIdx+1].IsTODO() || commits[endIdx+1].Action == models.ActionConflict { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} + } + } + + return nil +} + +func (self *LocalCommitsController) canMoveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { + if startIdx == 0 { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} + } + + if self.isRebasing() { + commits := self.c.Model().Commits + + if !commits[startIdx-1].IsTODO() || commits[startIdx-1].Action == models.ActionConflict { + return &types.DisabledReason{Text: self.c.Tr.CannotMoveAnyFurther} + } + } + + return nil +} + +// Ensures that if we are mid-rebase, we're only selecting valid commits (non-conflict TODO commits) +func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { + if !self.isRebasing() { + return nil + } + + for _, commit := range selectedCommits { + if !commit.IsTODO() { + return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits} + } + + if commit.Action == models.ActionConflict { + return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed} + } + } + + return nil +} + +func (self *LocalCommitsController) pickEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { + if !self.isRebasing() { + // if not rebasing, we're going to do a pull so we don't care about the selection + return nil + } + + return self.midRebaseCommandEnabled(selectedCommits, startIdx, endIdx) } diff --git a/pkg/i18n/chinese.go b/pkg/i18n/chinese.go index 8386bce1e..298353b88 100644 --- a/pkg/i18n/chinese.go +++ b/pkg/i18n/chinese.go @@ -217,8 +217,8 @@ func chineseTranslationSet() TranslationSet { ScrollDownMainPanel: "向下滚动主面板", AmendCommitTitle: "修改提交", AmendCommitPrompt: "您确定要使用暂存文件来修改此提交吗?", - DeleteCommitTitle: "删除提交", - DeleteCommitPrompt: "您确定要删除此提交吗?", + DropCommitTitle: "删除提交", + DropCommitPrompt: "您确定要删除此提交吗?", PullingStatus: "正在拉取", PushingStatus: "正在推送", FetchingStatus: "正在抓取", diff --git a/pkg/i18n/dutch.go b/pkg/i18n/dutch.go index 1e2eaa689..dc391d743 100644 --- a/pkg/i18n/dutch.go +++ b/pkg/i18n/dutch.go @@ -181,8 +181,8 @@ func dutchTranslationSet() TranslationSet { ScrollDownMainPanel: "Scroll naar beneden vanaf hoofdpaneel", AmendCommitTitle: "Commit wijzigen", AmendCommitPrompt: "Weet je zeker dat je deze commit wil wijzigen met de vorige staged bestanden?", - DeleteCommitTitle: "Verwijder commit", - DeleteCommitPrompt: "Weet je zeker dat je deze commit wil verwijderen?", + DropCommitTitle: "Verwijder commit", + DropCommitPrompt: "Weet je zeker dat je deze commit wil verwijderen?", PullingStatus: "Pullen", PushingStatus: "Pushen", FetchingStatus: "Fetchen", diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 35e7dd687..c40e61241 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -119,6 +119,7 @@ type TranslationSet struct { DeleteCommit string MoveDownCommit string MoveUpCommit string + CannotMoveAnyFurther string EditCommit string AmendToCommit string ResetAuthor string @@ -239,6 +240,7 @@ type TranslationSet struct { SimpleRebase string InteractiveRebase string InteractiveRebaseTooltip string + MustSelectTodoCommits string ConfirmMerge string FwdNoUpstream string FwdNoLocalUpstream string @@ -270,14 +272,15 @@ type TranslationSet struct { ScrollDownMainPanel string AmendCommitTitle string AmendCommitPrompt string - DeleteCommitTitle string - DeleteCommitPrompt string + DropCommitTitle string + DropCommitPrompt string PullingStatus string PushingStatus string FetchingStatus string SquashingStatus string FixingStatus string DeletingStatus string + DroppingStatus string MovingStatus string RebasingStatus string MergingStatus string @@ -686,8 +689,6 @@ type Log struct { CherryPickCommits string HandleUndo string HandleMidRebaseCommand string - MovingCommitUp string - MovingCommitDown string RemoveFile string CopyToClipboard string Remove string @@ -945,8 +946,8 @@ func EnglishTranslationSet() TranslationSet { UpdateRefHere: "Update branch '{{.ref}}' here", CannotSquashOrFixupFirstCommit: "There's no commit below to squash into", Fixup: "Fixup", - SureFixupThisCommit: "Are you sure you want to 'fixup' this commit? It will be merged into the commit below", - SureSquashThisCommit: "Are you sure you want to squash this commit into the commit below?", + SureFixupThisCommit: "Are you sure you want to 'fixup' the selected commit(s) into the commit below?", + SureSquashThisCommit: "Are you sure you want to squash the selected commit(s) into the commit below?", Squash: "Squash", PickCommit: "Pick commit (when mid-rebase)", RevertCommit: "Revert commit", @@ -954,6 +955,7 @@ func EnglishTranslationSet() TranslationSet { DeleteCommit: "Delete commit", MoveDownCommit: "Move commit down one", MoveUpCommit: "Move commit up one", + CannotMoveAnyFurther: "Cannot move any further", EditCommit: "Edit commit", AmendToCommit: "Amend commit with staged changes", ResetAuthor: "Reset author", @@ -1079,6 +1081,7 @@ func EnglishTranslationSet() TranslationSet { SimpleRebase: "Simple rebase", InteractiveRebase: "Interactive rebase", InteractiveRebaseTooltip: "Begin an interactive rebase with a break at the start, so you can update the TODO commits before continuing", + MustSelectTodoCommits: "When rebasing, this action only works on a selection of TODO commits.", ConfirmMerge: "Are you sure you want to merge '{{.selectedBranch}}' into '{{.checkedOutBranch}}'?", FwdNoUpstream: "Cannot fast-forward a branch with no upstream", FwdNoLocalUpstream: "Cannot fast-forward a branch whose remote is not registered locally", @@ -1110,14 +1113,15 @@ func EnglishTranslationSet() TranslationSet { ScrollDownMainPanel: "Scroll down main panel", AmendCommitTitle: "Amend commit", AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", - DeleteCommitTitle: "Delete commit", - DeleteCommitPrompt: "Are you sure you want to delete this commit?", + DropCommitTitle: "Drop commit", + DropCommitPrompt: "Are you sure you want to drop the selected commit(s)?", PullingStatus: "Pulling", PushingStatus: "Pushing", FetchingStatus: "Fetching", SquashingStatus: "Squashing", FixingStatus: "Fixing up", DeletingStatus: "Deleting", + DroppingStatus: "Dropping", MovingStatus: "Moving", RebasingStatus: "Rebasing", MergingStatus: "Merging", @@ -1626,8 +1630,6 @@ func EnglishTranslationSet() TranslationSet { CherryPickCommits: "Cherry-picking commits:\n'{{.commitLines}}'", HandleUndo: "Undoing last conflict resolution", HandleMidRebaseCommand: "Updating rebase action of commit {{.shortSha}} to '{{.action}}'", - MovingCommitUp: "Moving commit {{.shortSha}} up", - MovingCommitDown: "Moving commit {{.shortSha}} down", RemoveFile: "Deleting path '{{.path}}'", CopyToClipboard: "Copying '{{.str}}' to clipboard", Remove: "Removing '{{.filename}}'", diff --git a/pkg/i18n/japanese.go b/pkg/i18n/japanese.go index 3da17b097..864bd4aa3 100644 --- a/pkg/i18n/japanese.go +++ b/pkg/i18n/japanese.go @@ -221,8 +221,8 @@ func japaneseTranslationSet() TranslationSet { ScrollDownMainPanel: "メインパネルを下にスクロール", AmendCommitTitle: "Amendコミット", AmendCommitPrompt: "ステージされたファイルで現在のコミットをamendします。よろしいですか?", - DeleteCommitTitle: "コミットを削除", - DeleteCommitPrompt: "選択されたコミットを削除します。よろしいですか?", + DropCommitTitle: "コミットを削除", + DropCommitPrompt: "選択されたコミットを削除します。よろしいですか?", PullingStatus: "Pull中", PushingStatus: "Push中", FetchingStatus: "Fetch中", diff --git a/pkg/i18n/korean.go b/pkg/i18n/korean.go index 3c4d0ceab..14a70e06d 100644 --- a/pkg/i18n/korean.go +++ b/pkg/i18n/korean.go @@ -218,8 +218,8 @@ func koreanTranslationSet() TranslationSet { ScrollDownMainPanel: "메인 패널을 아래로로 스크롤", AmendCommitTitle: "Amend commit", AmendCommitPrompt: "Are you sure you want to amend this commit with your staged files?", - DeleteCommitTitle: "커밋 삭제", - DeleteCommitPrompt: "정말로 선택한 커밋을 삭제하시겠습니까?", + DropCommitTitle: "커밋 삭제", + DropCommitPrompt: "정말로 선택한 커밋을 삭제하시겠습니까?", PullingStatus: "업데이트 중", PushingStatus: "푸시 중", FetchingStatus: "패치 중", diff --git a/pkg/i18n/polish.go b/pkg/i18n/polish.go index e1515a948..373c7b771 100644 --- a/pkg/i18n/polish.go +++ b/pkg/i18n/polish.go @@ -147,8 +147,8 @@ func polishTranslationSet() TranslationSet { ScrollUp: "Przewiń w górę", AmendCommitTitle: "Popraw commit", AmendCommitPrompt: "Czy na pewno chcesz poprawić ten commit plikami z poczekalni?", - DeleteCommitTitle: "Usuń commit", - DeleteCommitPrompt: "Czy na pewno usunąć ten commit?", + DropCommitTitle: "Usuń commit", + DropCommitPrompt: "Czy na pewno usunąć ten commit?", PullingStatus: "Pobieranie zmian", PushingStatus: "Wysyłanie zmian", FetchingStatus: "Pobieram", diff --git a/pkg/i18n/russian.go b/pkg/i18n/russian.go index 1522a0f1c..dca93fd27 100644 --- a/pkg/i18n/russian.go +++ b/pkg/i18n/russian.go @@ -262,8 +262,8 @@ func RussianTranslationSet() TranslationSet { ScrollDownMainPanel: "Прокрутить вниз главную панель", AmendCommitTitle: "Править коммит (amend)", AmendCommitPrompt: "Вы уверены, что хотите править этот коммит проиндексированными файлами?", - DeleteCommitTitle: "Удалить коммит", - DeleteCommitPrompt: "Вы уверены, что хотите удалить этот коммит?", + DropCommitTitle: "Удалить коммит", + DropCommitPrompt: "Вы уверены, что хотите удалить этот коммит?", PullingStatus: "Получение и слияние изменении", PushingStatus: "Отправка изменении", FetchingStatus: "Получение изменении", diff --git a/pkg/i18n/traditional_chinese.go b/pkg/i18n/traditional_chinese.go index b9519bfcd..c2fe0bd5e 100644 --- a/pkg/i18n/traditional_chinese.go +++ b/pkg/i18n/traditional_chinese.go @@ -293,8 +293,8 @@ func traditionalChineseTranslationSet() TranslationSet { ScrollDownMainPanel: "向下捲動主面板", AmendCommitTitle: "修正提交", AmendCommitPrompt: "你確定要使用預存的檔案修正此提交嗎?", - DeleteCommitTitle: "刪除提交", - DeleteCommitPrompt: "你確定要刪除此提交嗎?", + DropCommitTitle: "刪除提交", + DropCommitPrompt: "你確定要刪除此提交嗎?", PullingStatus: "拉取", PushingStatus: "推送", FetchingStatus: "擷取", diff --git a/pkg/integration/tests/demo/undo.go b/pkg/integration/tests/demo/undo.go index 4b47e0726..830b1849f 100644 --- a/pkg/integration/tests/demo/undo.go +++ b/pkg/integration/tests/demo/undo.go @@ -22,8 +22,8 @@ var Undo = NewIntegrationTest(NewIntegrationTestArgs{ confirmCommitDrop := func() { t.ExpectPopup().Confirmation(). - Title(Equals("Delete commit")). - Content(Equals("Are you sure you want to delete this commit?")). + Title(Equals("Drop commit")). + Content(Equals("Are you sure you want to drop the selected commit(s)?")). Wait(500). Confirm() } diff --git a/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go b/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go index dbaa77b90..a6868e44f 100644 --- a/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go +++ b/pkg/integration/tests/interactive_rebase/drop_with_custom_comment_char.go @@ -23,8 +23,8 @@ var DropWithCustomCommentChar = NewIntegrationTest(NewIntegrationTestArgs{ Press(keys.Universal.Remove). Tap(func() { t.ExpectPopup().Confirmation(). - Title(Equals("Delete commit")). - Content(Equals("Are you sure you want to delete this commit?")). + Title(Equals("Drop commit")). + Content(Equals("Are you sure you want to drop the selected commit(s)?")). Confirm() }). Lines( diff --git a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go index 78cb875ac..88417ccdd 100644 --- a/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go +++ b/pkg/integration/tests/interactive_rebase/edit_non_todo_commit_during_rebase.go @@ -29,6 +29,6 @@ var EditNonTodoCommitDuringRebase = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("commit 01")). Press(keys.Universal.Edit) - t.ExpectToast(Contains("Can't perform this action during a rebase")) + t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) }, }) diff --git a/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go b/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go index 85a3df27c..61b14fafd 100644 --- a/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go +++ b/pkg/integration/tests/interactive_rebase/edit_the_confl_commit.go @@ -39,6 +39,6 @@ var EditTheConflCommit = NewIntegrationTest(NewIntegrationTestArgs{ NavigateToLine(Contains("<-- YOU ARE HERE --- commit three")). Press(keys.Commits.RenameCommit) - t.ExpectToast(Contains("Changing this kind of rebase todo entry is not allowed")) + t.ExpectToast(Contains("Disabled: Rewording commits while interactively rebasing is not currently supported")) }, }) diff --git a/pkg/integration/tests/interactive_rebase/fixup_second_commit.go b/pkg/integration/tests/interactive_rebase/fixup_second_commit.go index 57648035d..c5eec4a82 100644 --- a/pkg/integration/tests/interactive_rebase/fixup_second_commit.go +++ b/pkg/integration/tests/interactive_rebase/fixup_second_commit.go @@ -29,7 +29,7 @@ var FixupSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Fixup")). - Content(Equals("Are you sure you want to 'fixup' this commit? It will be merged into the commit below")). + Content(Equals("Are you sure you want to 'fixup' the selected commit(s) into the commit below?")). Confirm() }). Lines( diff --git a/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go new file mode 100644 index 000000000..6dcb12ea6 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/mid_rebase_range_select.go @@ -0,0 +1,205 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var MidRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Do various things with range selection in the commits view when mid-rebase", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CreateNCommits(10) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + TopLines( + Contains("commit 10").IsSelected(), + ). + NavigateToLine(Contains("commit 07")). + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("commit 10"), + Contains("commit 09"), + Contains("commit 08"), + Contains("commit 07").IsSelected(), + Contains("commit 06").IsSelected(), + Contains("commit 05"), + Contains("commit 04"), + ). + // Verify we can't perform an edit on multiple commits (it's not supported + // yet) + Press(keys.Universal.Edit). + Tap(func() { + // This ought to be a toast but I'm too lazy to implement that right now. + t.ExpectPopup().Alert(). + Title(Equals("Error")). + Content(Contains("Action does not support range selection, please select a single item")). + Confirm() + }). + NavigateToLine(Contains("commit 05")). + // Start a rebase + Press(keys.Universal.Edit). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("pick").Contains("commit 07"), + Contains("pick").Contains("commit 06"), + Contains("<-- YOU ARE HERE --- commit 05").IsSelected(), + Contains("commit 04"), + ). + SelectPreviousItem(). + // perform various actions on a range of commits + Press(keys.Universal.RangeSelectUp). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("pick").Contains("commit 07").IsSelected(), + Contains("pick").Contains("commit 06").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Commits.MarkCommitAsFixup). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("fixup").Contains("commit 07").IsSelected(), + Contains("fixup").Contains("commit 06").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Commits.PickCommit). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("pick").Contains("commit 07").IsSelected(), + Contains("pick").Contains("commit 06").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Universal.Edit). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("edit").Contains("commit 07").IsSelected(), + Contains("edit").Contains("commit 06").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Commits.SquashDown). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("squash").Contains("commit 07").IsSelected(), + Contains("squash").Contains("commit 06").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Commits.MoveDownCommit). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("squash").Contains("commit 07").IsSelected(), + Contains("squash").Contains("commit 06").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). + Press(keys.Commits.MoveUpCommit). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("squash").Contains("commit 07").IsSelected(), + Contains("squash").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit 08"), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Commits.MoveUpCommit). + TopLines( + Contains("pick").Contains("commit 10"), + Contains("squash").Contains("commit 07").IsSelected(), + Contains("squash").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Commits.MoveUpCommit). + TopLines( + Contains("squash").Contains("commit 07").IsSelected(), + Contains("squash").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + Press(keys.Commits.MoveUpCommit). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). + TopLines( + Contains("squash").Contains("commit 07").IsSelected(), + Contains("squash").Contains("commit 06").IsSelected(), + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08"), + Contains("<-- YOU ARE HERE --- commit 05"), + Contains("commit 04"), + ). + // Verify we can't perform an action on a range that includes both + // TODO and non-TODO commits + NavigateToLine(Contains("commit 08")). + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("squash").Contains("commit 07"), + Contains("squash").Contains("commit 06"), + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05").IsSelected(), + Contains("commit 04"), + ). + Press(keys.Commits.MarkCommitAsFixup). + Tap(func() { + t.ExpectToast(Contains("Disabled: When rebasing, this action only works on a selection of TODO commits.")) + }). + TopLines( + Contains("squash").Contains("commit 07"), + Contains("squash").Contains("commit 06"), + Contains("pick").Contains("commit 10"), + Contains("pick").Contains("commit 09"), + Contains("pick").Contains("commit 08").IsSelected(), + Contains("<-- YOU ARE HERE --- commit 05").IsSelected(), + Contains("commit 04"), + ). + // continue the rebase + Tap(func() { + t.Common().ContinueRebase() + }). + TopLines( + Contains("commit 10"), + Contains("commit 09"), + Contains("commit 08"), + Contains("commit 05"), + // selected indexes are retained, though we may want to clear it + // in future (not sure what the best behaviour is right now) + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/move.go b/pkg/integration/tests/interactive_rebase/move.go index 8eca1073f..3f1f23755 100644 --- a/pkg/integration/tests/interactive_rebase/move.go +++ b/pkg/integration/tests/interactive_rebase/move.go @@ -45,6 +45,9 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ ). // assert nothing happens upon trying to move beyond the last commit Press(keys.Commits.MoveDownCommit). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). Lines( Contains("commit 03"), Contains("commit 02"), @@ -74,6 +77,9 @@ var Move = NewIntegrationTest(NewIntegrationTestArgs{ ). // assert nothing happens upon trying to move beyond the first commit Press(keys.Commits.MoveUpCommit). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). Lines( Contains("commit 04").IsSelected(), Contains("commit 03"), diff --git a/pkg/integration/tests/interactive_rebase/move_in_rebase.go b/pkg/integration/tests/interactive_rebase/move_in_rebase.go index adce14409..48b74a7a4 100644 --- a/pkg/integration/tests/interactive_rebase/move_in_rebase.go +++ b/pkg/integration/tests/interactive_rebase/move_in_rebase.go @@ -45,8 +45,11 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ Contains("commit 03"), Contains("YOU ARE HERE").Contains("commit 01"), ). - Press(keys.Commits.MoveUpCommit). // assert we can't move past the top + Press(keys.Commits.MoveUpCommit). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). Lines( Contains("commit 02").IsSelected(), Contains("commit 04"), @@ -69,6 +72,9 @@ var MoveInRebase = NewIntegrationTest(NewIntegrationTestArgs{ ). // assert we can't move past the bottom Press(keys.Commits.MoveDownCommit). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). Lines( Contains("commit 04"), Contains("commit 03"), diff --git a/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go b/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go new file mode 100644 index 000000000..d30ae0d64 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/outside_rebase_range_select.go @@ -0,0 +1,155 @@ +package interactive_rebase + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var OutsideRebaseRangeSelect = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Do various things with range selection in the commits view when outside rebase", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell. + CreateNCommits(10) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + TopLines( + Contains("commit 10").IsSelected(), + ). + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("commit 10").IsSelected(), + Contains("commit 09").IsSelected(), + Contains("commit 08"), + ). + // Drop commits + Press(keys.Universal.Remove). + Tap(func() { + t.ExpectPopup().Confirmation(). + Title(Equals("Drop commit")). + Content(Contains("Are you sure you want to drop the selected commit(s)?")). + Confirm() + }). + TopLines( + Contains("commit 08").IsSelected(), + Contains("commit 07"), + ). + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("commit 08").IsSelected(), + Contains("commit 07").IsSelected(), + Contains("commit 06"), + ). + // Squash commits + Press(keys.Commits.SquashDown). + Tap(func() { + t.ExpectPopup().Confirmation(). + Title(Equals("Squash")). + Content(Contains("Are you sure you want to squash the selected commit(s) into the commit below?")). + Confirm() + }). + TopLines( + Contains("commit 06").IsSelected(), + Contains("commit 05"), + Contains("commit 04"), + ). + // Verify commit messages are concatenated + Tap(func() { + t.Views().Main(). + ContainsLines( + Contains("commit 06"), + AnyString(), + Contains("commit 07"), + AnyString(), + Contains("commit 08"), + ) + }). + // Fixup commits + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("commit 06").IsSelected(), + Contains("commit 05").IsSelected(), + Contains("commit 04"), + ). + Press(keys.Commits.MarkCommitAsFixup). + Tap(func() { + t.ExpectPopup().Confirmation(). + Title(Equals("Fixup")). + Content(Contains("Are you sure you want to 'fixup' the selected commit(s) into the commit below?")). + Confirm() + }). + TopLines( + Contains("commit 04").IsSelected(), + Contains("commit 03"), + Contains("commit 02"), + ). + // Verify commit messages are dropped + Tap(func() { + t.Views().Main(). + Content( + Contains("commit 04"). + DoesNotContain("commit 06"). + DoesNotContain("commit 05"), + ) + }). + Press(keys.Universal.RangeSelectDown). + TopLines( + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + Contains("commit 02"), + ). + // Move commits + Press(keys.Commits.MoveDownCommit). + TopLines( + Contains("commit 02"), + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + Contains("commit 01"), + ). + Press(keys.Commits.MoveDownCommit). + TopLines( + Contains("commit 02"), + Contains("commit 01"), + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + ). + Press(keys.Commits.MoveDownCommit). + TopLines( + Contains("commit 02"), + Contains("commit 01"), + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + ). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). + Press(keys.Commits.MoveUpCommit). + TopLines( + Contains("commit 02"), + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + Contains("commit 01"), + ). + Press(keys.Commits.MoveUpCommit). + TopLines( + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + Contains("commit 02"), + Contains("commit 01"), + ). + Press(keys.Commits.MoveUpCommit). + Tap(func() { + t.ExpectToast(Contains("Disabled: Cannot move any further")) + }). + TopLines( + Contains("commit 04").IsSelected(), + Contains("commit 03").IsSelected(), + Contains("commit 02"), + Contains("commit 01"), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go b/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go index 931c52015..6ba313f7a 100644 --- a/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go +++ b/pkg/integration/tests/interactive_rebase/squash_down_second_commit.go @@ -27,7 +27,7 @@ var SquashDownSecondCommit = NewIntegrationTest(NewIntegrationTestArgs{ Tap(func() { t.ExpectPopup().Confirmation(). Title(Equals("Squash")). - Content(Equals("Are you sure you want to squash this commit into the commit below?")). + Content(Equals("Are you sure you want to squash the selected commit(s) into the commit below?")). Confirm() }). Lines( diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 7f5cc23fa..f0437b30d 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -165,6 +165,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.Move, interactive_rebase.MoveInRebase, interactive_rebase.MoveWithCustomCommentChar, + interactive_rebase.OutsideRebaseRangeSelect, interactive_rebase.PickRescheduled, interactive_rebase.QuickStart, interactive_rebase.Rebase, diff --git a/pkg/integration/tests/undo/undo_checkout_and_drop.go b/pkg/integration/tests/undo/undo_checkout_and_drop.go index 4dc5a0a33..6c095b029 100644 --- a/pkg/integration/tests/undo/undo_checkout_and_drop.go +++ b/pkg/integration/tests/undo/undo_checkout_and_drop.go @@ -24,8 +24,8 @@ var UndoCheckoutAndDrop = NewIntegrationTest(NewIntegrationTestArgs{ confirmCommitDrop := func() { t.ExpectPopup().Confirmation(). - Title(Equals("Delete commit")). - Content(Equals("Are you sure you want to delete this commit?")). + Title(Equals("Drop commit")). + Content(Equals("Are you sure you want to drop the selected commit(s)?")). Confirm() } diff --git a/pkg/integration/tests/undo/undo_drop.go b/pkg/integration/tests/undo/undo_drop.go index 3a99fce8b..2d4d2d6a9 100644 --- a/pkg/integration/tests/undo/undo_drop.go +++ b/pkg/integration/tests/undo/undo_drop.go @@ -19,8 +19,8 @@ var UndoDrop = NewIntegrationTest(NewIntegrationTestArgs{ Run: func(t *TestDriver, keys config.KeybindingConfig) { confirmCommitDrop := func() { t.ExpectPopup().Confirmation(). - Title(Equals("Delete commit")). - Content(Equals("Are you sure you want to delete this commit?")). + Title(Equals("Drop commit")). + Content(Equals("Are you sure you want to drop the selected commit(s)?")). Confirm() } diff --git a/pkg/utils/rebase_todo.go b/pkg/utils/rebase_todo.go index 08a9ca872..e4bfe25d0 100644 --- a/pkg/utils/rebase_todo.go +++ b/pkg/utils/rebase_todo.go @@ -9,27 +9,46 @@ import ( "github.com/samber/lo" ) -// Read a git-rebase-todo file, change the action for the given sha to -// newAction, and write it back -func EditRebaseTodo(filePath string, sha string, oldAction todo.TodoCommand, newAction todo.TodoCommand, commentChar byte) error { +type Todo struct { + Sha string + Action todo.TodoCommand +} + +// In order to change a TODO in git-rebase-todo, we need to specify the old action, +// because sometimes the same sha appears multiple times in the file (e.g. in a pick +// and later in a merge) +type TodoChange struct { + Sha string + OldAction todo.TodoCommand + NewAction todo.TodoCommand +} + +// Read a git-rebase-todo file, change the actions for the given commits, +// and write it back +func EditRebaseTodo(filePath string, changes []TodoChange, commentChar byte) error { todos, err := ReadRebaseTodoFile(filePath, commentChar) if err != nil { return err } + matchCount := 0 for i := range todos { t := &todos[i] - // Comparing just the sha is not enough; we need to compare both the - // action and the sha, as the sha could appear multiple times (e.g. in a - // pick and later in a merge) - if t.Command == oldAction && equalShas(t.Commit, sha) { - t.Command = newAction - return WriteRebaseTodoFile(filePath, todos, commentChar) + // This is a nested loop, but it's ok because the number of todos should be small + for _, change := range changes { + if t.Command == change.OldAction && equalShas(t.Commit, change.Sha) { + matchCount++ + t.Command = change.NewAction + } } } - // Should never get here - return fmt.Errorf("Todo %s not found in git-rebase-todo", sha) + if matchCount < len(changes) { + // Should never get here + return fmt.Errorf("Some todos not found in git-rebase-todo") + } + + return WriteRebaseTodoFile(filePath, todos, commentChar) } func equalShas(a, b string) bool { @@ -73,24 +92,24 @@ func PrependStrToTodoFile(filePath string, linesToPrepend []byte) error { return os.WriteFile(filePath, linesToPrepend, 0o644) } -func MoveTodoDown(fileName string, sha string, action todo.TodoCommand, commentChar byte) error { +func MoveTodosDown(fileName string, todosToMove []Todo, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } - rearrangedTodos, err := moveTodoDown(todos, sha, action) + rearrangedTodos, err := moveTodosDown(todos, todosToMove) if err != nil { return err } return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar) } -func MoveTodoUp(fileName string, sha string, action todo.TodoCommand, commentChar byte) error { +func MoveTodosUp(fileName string, todosToMove []Todo, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { return err } - rearrangedTodos, err := moveTodoUp(todos, sha, action) + rearrangedTodos, err := moveTodosUp(todos, todosToMove) if err != nil { return err } @@ -102,6 +121,11 @@ func moveTodoDown(todos []todo.Todo, sha string, action todo.TodoCommand) ([]tod return lo.Reverse(rearrangedTodos), err } +func moveTodosDown(todos []todo.Todo, todosToMove []Todo) ([]todo.Todo, error) { + rearrangedTodos, err := moveTodosUp(lo.Reverse(todos), lo.Reverse(todosToMove)) + return lo.Reverse(rearrangedTodos), err +} + func moveTodoUp(todos []todo.Todo, sha string, action todo.TodoCommand) ([]todo.Todo, error) { _, sourceIdx, ok := lo.FindIndexOf(todos, func(t todo.Todo) bool { // Comparing just the sha is not enough; we need to compare both the @@ -134,6 +158,19 @@ func moveTodoUp(todos []todo.Todo, sha string, action todo.TodoCommand) ([]todo. return rearrangedTodos, nil } +func moveTodosUp(todos []todo.Todo, todosToMove []Todo) ([]todo.Todo, error) { + for _, todoToMove := range todosToMove { + var newTodos []todo.Todo + newTodos, err := moveTodoUp(todos, todoToMove.Sha, todoToMove.Action) + if err != nil { + return nil, err + } + todos = newTodos + } + + return todos, nil +} + func MoveFixupCommitDown(fileName string, originalSha string, fixupSha string, commentChar byte) error { todos, err := ReadRebaseTodoFile(fileName, commentChar) if err != nil { From 41d5f4dbb5571db2b8812e9000af78cfab0e7d05 Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Tue, 23 Jan 2024 17:03:29 +1100 Subject: [PATCH 4/4] Disallow updating non-standard TODO lines when rebasing --- .../controllers/local_commits_controller.go | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index e1d20b554..2062907f0 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -1028,7 +1028,7 @@ func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*m return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits} } - if commit.Action == models.ActionConflict { + if !isChangeOfRebaseTodoAllowed(commit.Action) { return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed} } } @@ -1036,6 +1036,24 @@ func (self *LocalCommitsController) midRebaseCommandEnabled(selectedCommits []*m return nil } +// These actions represent standard things you might want to do with a commit, +// as opposed to TODO actions like 'merge', 'update-ref', etc. +var standardActions = []todo.TodoCommand{ + todo.Pick, + todo.Drop, + todo.Edit, + todo.Fixup, + todo.Squash, + todo.Reword, +} + +func isChangeOfRebaseTodoAllowed(oldAction todo.TodoCommand) bool { + // Only allow updating a standard action, meaning we disallow + // updating a merge commit or update ref commit (until we decide what would be sensible + // to do in those cases) + return lo.Contains(standardActions, oldAction) +} + func (self *LocalCommitsController) pickEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason { if !self.isRebasing() { // if not rebasing, we're going to do a pull so we don't care about the selection