mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-28 10:15:32 -05:00
2029 lines
67 KiB
Go
2029 lines
67 KiB
Go
package controllers
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-errors/errors"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
|
|
"github.com/jesseduffield/lazygit/pkg/commands/models"
|
|
"github.com/jesseduffield/lazygit/pkg/gocui"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/context/traits"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/style"
|
|
"github.com/jesseduffield/lazygit/pkg/gui/types"
|
|
"github.com/jesseduffield/lazygit/pkg/utils"
|
|
"github.com/samber/lo"
|
|
"github.com/stefanhaller/git-todo-parser/todo"
|
|
)
|
|
|
|
// after selecting the 200th commit, we'll load in all the rest
|
|
const COMMIT_THRESHOLD = 200
|
|
|
|
// How long a commit move may take before the drop indicator switches to a
|
|
// "moving commits here" spinner; quick moves stay free of flicker.
|
|
const commitDragMovingIndicatorDelay = 200 * time.Millisecond
|
|
|
|
type (
|
|
PullFilesFn func() error
|
|
)
|
|
|
|
type LocalCommitsController struct {
|
|
baseController
|
|
*ListControllerTrait[*models.Commit]
|
|
c *ControllerCommon
|
|
|
|
pullFiles PullFilesFn
|
|
commitDrag *commitDragState
|
|
dragAutoscroller *helpers.DragAutoscroller
|
|
movingCommitsIndicatorStop chan struct{}
|
|
}
|
|
|
|
// commitDragState tracks a mouse drag that moves the selected commits. It is
|
|
// created when the left button is pressed on the current selection, and lives
|
|
// until the button is released or the drag is canceled.
|
|
type commitDragState struct {
|
|
// Model index that was pressed; releasing without having moved collapses
|
|
// the selection to this commit, like a plain click would.
|
|
pressedIndex int
|
|
// Bounds of the selection at press time.
|
|
startIndex int
|
|
endIndex int
|
|
// Identifying information of the dragged commits, so that they can be
|
|
// found again on release even if the model was refreshed during the drag.
|
|
commitIdentities []commitDragIdentity
|
|
// Cursor and range-start position relative to startIndex, for restoring
|
|
// the selection after the move.
|
|
selectedOffset int
|
|
rangeStartOffset int
|
|
rangeSelectMode traits.RangeSelectMode
|
|
// Smallest and largest allowed insertion index. During a rebase this
|
|
// restricts the drag to the contiguous block of movable todos around the
|
|
// selection.
|
|
minInsertion int
|
|
maxInsertion int
|
|
// Current insertion index, or -1 if dropping wouldn't move anything
|
|
// (pointer over the dragged block itself).
|
|
insertionIndex int
|
|
// Whether any drag motion arrived since the press; distinguishes a drag
|
|
// from a plain click on the selection.
|
|
hasMoved bool
|
|
}
|
|
|
|
type commitDragIdentity struct {
|
|
hash string
|
|
name string
|
|
action todo.TodoCommand
|
|
actionFlag string
|
|
}
|
|
|
|
var _ types.IController = &LocalCommitsController{}
|
|
|
|
func NewLocalCommitsController(
|
|
c *ControllerCommon,
|
|
pullFiles PullFilesFn,
|
|
) *LocalCommitsController {
|
|
controller := &LocalCommitsController{
|
|
baseController: baseController{},
|
|
c: c,
|
|
pullFiles: pullFiles,
|
|
ListControllerTrait: NewListControllerTrait(
|
|
c,
|
|
c.Contexts().LocalCommits,
|
|
c.Contexts().LocalCommits.GetSelected,
|
|
c.Contexts().LocalCommits.GetSelectedItems,
|
|
),
|
|
}
|
|
controller.dragAutoscroller = helpers.NewDragAutoscroller(
|
|
c.HelperCommon,
|
|
c.Contexts().LocalCommits,
|
|
controller.canCommitDragAutoscroll,
|
|
controller.handleCommitDragAutoscroll,
|
|
)
|
|
return controller
|
|
}
|
|
|
|
func (self *LocalCommitsController) GetMouseKeybindings(types.KeybindingsOpts) []*gocui.ViewMouseBinding {
|
|
viewName := self.context().GetViewName()
|
|
return []*gocui.ViewMouseBinding{
|
|
{
|
|
ViewName: viewName,
|
|
FocusedView: viewName,
|
|
Key: gocui.MouseLeft,
|
|
Handler: self.handleCommitDragPress,
|
|
},
|
|
{
|
|
ViewName: viewName,
|
|
FocusedView: viewName,
|
|
Key: gocui.MouseLeft,
|
|
Modifier: gocui.ModMotion,
|
|
Handler: self.handleCommitDrag,
|
|
},
|
|
{
|
|
ViewName: viewName,
|
|
FocusedView: viewName,
|
|
Key: gocui.MouseRelease,
|
|
Handler: self.handleCommitDragRelease,
|
|
},
|
|
}
|
|
}
|
|
|
|
func (self *LocalCommitsController) handleCommitDragPress(opts gocui.ViewMouseBindingOpts) error {
|
|
context := self.context()
|
|
pressedIndex := context.ViewIndexToModelIndex(opts.Y)
|
|
startIndex, endIndex := context.GetSelectionRange()
|
|
selectedIndex, rangeStartIndex, rangeSelectMode := context.GetSelectionRangeAndMode()
|
|
selectedCommits, _, _ := context.GetSelectedItems()
|
|
// Only a single press on the current selection (of commits that may be
|
|
// moved) starts a drag; everything else falls through to the generic
|
|
// list click handling, i.e. selecting the pressed line, double-click
|
|
// actions, or dragging out a range selection. The view-index comparison
|
|
// rejects presses on section headers, which map to the model index of a
|
|
// nearby commit.
|
|
if opts.IsDoubleClick ||
|
|
pressedIndex < startIndex || pressedIndex > endIndex ||
|
|
context.ModelIndexToViewIndex(pressedIndex) != opts.Y ||
|
|
self.midRebaseMoveCommandEnabled(selectedCommits, startIndex, endIndex) != nil {
|
|
return gocui.ErrKeybindingNotHandled
|
|
}
|
|
|
|
minInsertion, maxInsertion := self.commitDragInsertionBounds(startIndex, endIndex)
|
|
self.commitDrag = &commitDragState{
|
|
pressedIndex: pressedIndex,
|
|
startIndex: startIndex,
|
|
endIndex: endIndex,
|
|
commitIdentities: lo.Map(selectedCommits, func(commit *models.Commit, _ int) commitDragIdentity {
|
|
return commitDragIdentityForCommit(commit)
|
|
}),
|
|
selectedOffset: selectedIndex - startIndex,
|
|
rangeStartOffset: rangeStartIndex - startIndex,
|
|
rangeSelectMode: rangeSelectMode,
|
|
minInsertion: minInsertion,
|
|
maxInsertion: maxInsertion,
|
|
insertionIndex: -1,
|
|
}
|
|
self.restoreCommitDragHighlight()
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) commitDragInsertionBounds(startIndex int, endIndex int) (int, int) {
|
|
commits := self.c.Model().Commits
|
|
if !self.isRebasing() {
|
|
return 0, len(commits)
|
|
}
|
|
|
|
minInsertion := startIndex
|
|
for minInsertion > 0 && commits[minInsertion-1].IsTODO() && commits[minInsertion-1].Status != models.StatusConflicted {
|
|
minInsertion--
|
|
}
|
|
maxInsertion := endIndex + 1
|
|
for maxInsertion < len(commits) && commits[maxInsertion].IsTODO() && commits[maxInsertion].Status != models.StatusConflicted {
|
|
maxInsertion++
|
|
}
|
|
return minInsertion, maxInsertion
|
|
}
|
|
|
|
func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBindingOpts) error {
|
|
if self.commitDrag == nil {
|
|
return gocui.ErrKeybindingNotHandled
|
|
}
|
|
|
|
self.commitDrag.hasMoved = true
|
|
if self.updateCommitDragInsertion(opts.Y) {
|
|
self.c.PostRefreshUpdateKeepingScrollPosition(self.context())
|
|
}
|
|
originY := self.context().GetView().OriginY()
|
|
self.dragAutoscroller.Update(opts.Y - originY)
|
|
self.restoreCommitDragHighlight()
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) updateCommitDragInsertion(viewIndex int) bool {
|
|
insertionIndex := self.commitDragInsertionIndex(viewIndex)
|
|
if insertionIndex >= self.commitDrag.startIndex && insertionIndex <= self.commitDrag.endIndex+1 {
|
|
insertionIndex = -1
|
|
}
|
|
if insertionIndex == self.commitDrag.insertionIndex {
|
|
return false
|
|
}
|
|
|
|
self.commitDrag.insertionIndex = insertionIndex
|
|
if insertionIndex < 0 {
|
|
self.context().ClearDropInsertionIndex()
|
|
} else {
|
|
self.context().SetDropInsertionIndex(insertionIndex)
|
|
}
|
|
return true
|
|
}
|
|
|
|
// gocui moves the view cursor to the pointer position before invoking our
|
|
// handlers; move it back so that the dragged commits stay highlighted for the
|
|
// whole duration of the drag.
|
|
func (self *LocalCommitsController) restoreCommitDragHighlight() {
|
|
state := self.commitDrag
|
|
context := self.context()
|
|
view := context.GetView()
|
|
selectedIndex := state.startIndex + state.selectedOffset
|
|
rangeStartIndex := state.startIndex + state.rangeStartOffset
|
|
|
|
view.SetCursorY(context.ModelIndexToViewIndex(selectedIndex) - view.OriginY())
|
|
view.SetRangeSelectStart(context.ModelIndexToViewIndex(rangeStartIndex))
|
|
}
|
|
|
|
func (self *LocalCommitsController) commitDragInsertionIndex(viewIndex int) int {
|
|
context := self.context()
|
|
if viewIndex < 0 {
|
|
return self.commitDrag.minInsertion
|
|
}
|
|
if viewIndex >= context.TotalContentHeight() {
|
|
return self.commitDrag.maxInsertion
|
|
}
|
|
|
|
// Rows above the dragged block insert before the pointed-at commit, rows
|
|
// below it insert after it, so that in both directions the line under
|
|
// the pointer is the one that makes way.
|
|
modelIndex := context.ViewIndexToModelIndex(viewIndex)
|
|
insertionIndex := modelIndex
|
|
if modelIndex > self.commitDrag.endIndex {
|
|
insertionIndex++
|
|
}
|
|
return max(self.commitDrag.minInsertion, min(insertionIndex, self.commitDrag.maxInsertion))
|
|
}
|
|
|
|
func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindingOpts) error {
|
|
if self.commitDrag == nil {
|
|
return gocui.ErrKeybindingNotHandled
|
|
}
|
|
|
|
state := self.commitDrag
|
|
self.dragAutoscroller.Cancel()
|
|
self.commitDrag = nil
|
|
|
|
if !state.hasMoved {
|
|
self.context().ClearDropInsertionIndex()
|
|
self.context().SetSelection(state.pressedIndex)
|
|
self.c.PostRefreshUpdate(self.context())
|
|
return nil
|
|
}
|
|
if state.insertionIndex < 0 {
|
|
self.context().ClearDropInsertionIndex()
|
|
self.c.PostRefreshUpdate(self.context())
|
|
return nil
|
|
}
|
|
|
|
offset := state.insertionIndex - state.startIndex
|
|
if state.insertionIndex > state.endIndex {
|
|
offset = state.insertionIndex - state.endIndex - 1
|
|
}
|
|
selectedCommits, startIndex, endIndex, found := findCommitDragBlock(
|
|
self.context().GetItems(), state.commitIdentities,
|
|
)
|
|
if !found {
|
|
self.context().ClearDropInsertionIndex()
|
|
self.c.PostRefreshUpdate(self.context())
|
|
return nil
|
|
}
|
|
self.context().SetSelectionRangeAndMode(
|
|
startIndex+state.selectedOffset,
|
|
startIndex+state.rangeStartOffset,
|
|
state.rangeSelectMode,
|
|
)
|
|
self.startMovingCommitsIndicator(state.insertionIndex)
|
|
if err := self.move(selectedCommits, startIndex, endIndex, offset,
|
|
func() error { self.stopMovingCommitsIndicator(); return nil }); err != nil {
|
|
self.stopMovingCommitsIndicator()
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// startMovingCommitsIndicator keeps the drop indicator visible while the move
|
|
// is running, turning it into a spinner once the grace period elapses. The
|
|
// ticker goroutine only ever touches state from the UI thread, where the
|
|
// comparison against the current stop channel makes late callbacks harmless.
|
|
func (self *LocalCommitsController) startMovingCommitsIndicator(insertionIndex int) {
|
|
self.stopMovingCommitsIndicatorTicker()
|
|
stop := make(chan struct{})
|
|
self.movingCommitsIndicatorStop = stop
|
|
go utils.Safe(func() {
|
|
graceTimer := time.NewTimer(commitDragMovingIndicatorDelay)
|
|
defer graceTimer.Stop()
|
|
select {
|
|
case <-graceTimer.C:
|
|
self.c.OnUIThreadContentOnlyBackground(func() error {
|
|
if self.movingCommitsIndicatorStop == stop {
|
|
self.context().SetMovingCommitsInsertionIndex(insertionIndex)
|
|
self.context().HandleRender()
|
|
}
|
|
return nil
|
|
})
|
|
case <-stop:
|
|
return
|
|
}
|
|
|
|
rate := time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate)
|
|
ticker := time.NewTicker(rate)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
self.c.OnUIThreadContentOnlyBackground(func() error {
|
|
if self.movingCommitsIndicatorStop == stop {
|
|
self.context().HandleRender()
|
|
}
|
|
return nil
|
|
})
|
|
case <-stop:
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) stopMovingCommitsIndicator() {
|
|
self.stopMovingCommitsIndicatorTicker()
|
|
self.context().ClearDropInsertionIndex()
|
|
self.c.PostRefreshUpdate(self.context())
|
|
}
|
|
|
|
func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() {
|
|
if self.movingCommitsIndicatorStop != nil {
|
|
close(self.movingCommitsIndicatorStop)
|
|
self.movingCommitsIndicatorStop = nil
|
|
}
|
|
}
|
|
|
|
func commitDragIdentityForCommit(commit *models.Commit) commitDragIdentity {
|
|
return commitDragIdentity{
|
|
hash: commit.Hash(),
|
|
name: commit.Name,
|
|
action: commit.Action,
|
|
actionFlag: commit.ActionFlag,
|
|
}
|
|
}
|
|
|
|
// findCommitDragBlock locates the dragged commits in the (possibly refreshed)
|
|
// commit list by their identity rather than by the indices recorded at press
|
|
// time. If they no longer exist as a contiguous block, or more than one block
|
|
// matches, we give up rather than guess.
|
|
func findCommitDragBlock(
|
|
commits []*models.Commit, identities []commitDragIdentity,
|
|
) ([]*models.Commit, int, int, bool) {
|
|
matchStart := -1
|
|
for startIndex := 0; startIndex+len(identities) <= len(commits); startIndex++ {
|
|
matches := true
|
|
for offset, identity := range identities {
|
|
if commitDragIdentityForCommit(commits[startIndex+offset]) != identity {
|
|
matches = false
|
|
break
|
|
}
|
|
}
|
|
if matches {
|
|
if matchStart >= 0 {
|
|
return nil, -1, -1, false
|
|
}
|
|
matchStart = startIndex
|
|
}
|
|
}
|
|
|
|
if matchStart < 0 {
|
|
return nil, -1, -1, false
|
|
}
|
|
endIndex := matchStart + len(identities) - 1
|
|
return commits[matchStart : endIndex+1], matchStart, endIndex, true
|
|
}
|
|
|
|
func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) {
|
|
return func(types.OnFocusLostOpts) {
|
|
if self.commitDrag == nil {
|
|
return
|
|
}
|
|
|
|
self.cancelCommitDrag()
|
|
}
|
|
}
|
|
|
|
func (self *LocalCommitsController) cancelCommitDrag() {
|
|
self.dragAutoscroller.Cancel()
|
|
self.commitDrag = nil
|
|
self.c.GocuiGui().CancelMouseCapture()
|
|
self.context().ClearDropInsertionIndex()
|
|
self.c.PostRefreshUpdate(self.context())
|
|
}
|
|
|
|
func (self *LocalCommitsController) handleCommitDragCancel() error {
|
|
if self.commitDrag == nil {
|
|
return gocui.ErrKeybindingNotHandled
|
|
}
|
|
|
|
self.cancelCommitDrag()
|
|
return nil
|
|
}
|
|
|
|
// Stop autoscrolling once the insertion point has reached the end of the
|
|
// allowed range in the scroll direction; e.g. during a rebase there is no
|
|
// point in scrolling on into the section of real commits.
|
|
func (self *LocalCommitsController) canCommitDragAutoscroll(direction int) bool {
|
|
state := self.commitDrag
|
|
if state == nil {
|
|
return false
|
|
}
|
|
if direction < 0 {
|
|
return state.insertionIndex != state.minInsertion
|
|
}
|
|
return state.insertionIndex != state.maxInsertion
|
|
}
|
|
|
|
func (self *LocalCommitsController) handleCommitDragAutoscroll(viewIndex int) bool {
|
|
if self.commitDrag == nil {
|
|
return false
|
|
}
|
|
|
|
self.updateCommitDragInsertion(viewIndex)
|
|
self.context().SetNeedRerenderVisibleLines()
|
|
self.context().HandleRender()
|
|
self.restoreCommitDragHighlight()
|
|
return self.canCommitDragAutoscroll(self.dragAutoscroller.Direction())
|
|
}
|
|
|
|
func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
|
|
editCommitKey := opts.Config.Universal.Edit
|
|
|
|
bindings := []*types.Binding{
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Return),
|
|
Handler: self.handleCommitDragCancel,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.SquashDown),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.squashDown)),
|
|
GetDisabledReason: self.require(
|
|
self.itemRangeSelected(
|
|
self.midRebaseCommandEnabled,
|
|
self.canSquashOrFixup,
|
|
),
|
|
),
|
|
Description: self.c.Tr.Squash,
|
|
Tooltip: self.c.Tr.SquashTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.MarkCommitAsFixup),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.fixup)),
|
|
GetDisabledReason: self.require(
|
|
self.itemRangeSelected(
|
|
self.midRebaseCommandEnabled,
|
|
self.canSquashOrFixup,
|
|
),
|
|
),
|
|
Description: self.c.Tr.Fixup,
|
|
Tooltip: self.c.Tr.FixupTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.SetFixupMessage),
|
|
Handler: self.withItem(self.setFixupMessage),
|
|
GetDisabledReason: self.require(
|
|
self.singleItemSelected(self.canSetFixupMessage),
|
|
),
|
|
Description: self.c.Tr.SetFixupMessage,
|
|
Tooltip: self.c.Tr.SetFixupMessageTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.RenameCommit),
|
|
Handler: self.withItem(self.reword),
|
|
GetDisabledReason: self.require(
|
|
self.singleItemSelected(self.rewordEnabled),
|
|
),
|
|
Description: self.c.Tr.Reword,
|
|
Tooltip: self.c.Tr.CommitRewordTooltip,
|
|
DisplayOnScreen: true,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.RenameCommitWithEditor),
|
|
Handler: self.withItem(self.rewordEditor),
|
|
GetDisabledReason: self.require(
|
|
self.singleItemSelected(self.rewordEnabled),
|
|
),
|
|
Description: self.c.Tr.RewordCommitEditor,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.Remove),
|
|
Handler: self.withItemsRange(self.drop),
|
|
GetDisabledReason: self.require(
|
|
self.itemRangeSelected(
|
|
self.canDropCommits,
|
|
),
|
|
),
|
|
Description: self.c.Tr.DropCommit,
|
|
Tooltip: self.c.Tr.DropCommitTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(editCommitKey),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.edit)),
|
|
GetDisabledReason: self.require(
|
|
self.itemRangeSelected(self.midRebaseCommandEnabled),
|
|
),
|
|
Description: self.c.Tr.EditCommit,
|
|
ShortDescription: self.c.Tr.Edit,
|
|
Tooltip: self.c.Tr.EditCommitTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
// The user-facing description here is 'Start interactive rebase' but internally
|
|
// we're calling it 'quick-start interactive rebase' to differentiate it from
|
|
// when you manually select the base commit.
|
|
Keys: opts.GetKeys(opts.Config.Commits.StartInteractiveRebase),
|
|
Handler: opts.Guards.OutsideFilterMode(self.quickStartInteractiveRebase),
|
|
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": editCommitKey.String(),
|
|
}),
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.PickCommit),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItems(self.pick)),
|
|
GetDisabledReason: self.require(
|
|
self.itemRangeSelected(self.pickEnabled),
|
|
),
|
|
Description: self.c.Tr.Pick,
|
|
Tooltip: self.c.Tr.PickCommitTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.CreateFixupCommit),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItem(self.createFixupCommit)),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.CreateFixupCommit,
|
|
Tooltip: utils.ResolvePlaceholderString(
|
|
self.c.Tr.CreateFixupCommitTooltip,
|
|
map[string]string{
|
|
"squashAbove": opts.Config.Commits.SquashAboveCommits.String(),
|
|
},
|
|
),
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.SquashAboveCommits),
|
|
Handler: opts.Guards.OutsideFilterMode(self.squashFixupCommits),
|
|
GetDisabledReason: self.require(
|
|
self.notMidRebase(self.c.Tr.AlreadyRebasing),
|
|
),
|
|
Description: self.c.Tr.SquashAboveCommits,
|
|
Tooltip: self.c.Tr.SquashAboveCommitsTooltip,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.MoveDownCommit),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.moveDown)),
|
|
GetDisabledReason: self.require(self.itemRangeSelected(
|
|
self.midRebaseMoveCommandEnabled,
|
|
self.canMoveDown,
|
|
)),
|
|
Description: self.c.Tr.MoveDownCommit,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.MoveUpCommit),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItemsRange(self.moveUp)),
|
|
GetDisabledReason: self.require(self.itemRangeSelected(
|
|
self.midRebaseMoveCommandEnabled,
|
|
self.canMoveUp,
|
|
)),
|
|
Description: self.c.Tr.MoveUpCommit,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.PasteCommits),
|
|
Handler: opts.Guards.OutsideFilterMode(self.paste),
|
|
GetDisabledReason: self.require(self.canPaste),
|
|
Description: self.c.Tr.PasteCommits,
|
|
DisplayStyle: &style.FgCyan,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.MarkCommitAsBaseForRebase),
|
|
Handler: opts.Guards.OutsideFilterMode(self.withItem(self.markAsBaseCommit)),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.MarkAsBaseCommit,
|
|
Tooltip: self.c.Tr.MarkAsBaseCommitTooltip,
|
|
},
|
|
// overriding this navigation keybinding because we might need to load
|
|
// more commits on demand
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Universal.StartSearch),
|
|
Handler: self.openSearch,
|
|
Description: self.c.Tr.StartSearch,
|
|
Tag: "navigation",
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.AmendToCommit),
|
|
Handler: self.withItem(self.amendTo),
|
|
GetDisabledReason: self.require(self.singleItemSelected(self.canAmend)),
|
|
Description: self.c.Tr.Amend,
|
|
Tooltip: self.c.Tr.AmendCommitTooltip,
|
|
DisplayOnScreen: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.ResetCommitAuthor),
|
|
Handler: self.withItemsRange(self.amendAttribute),
|
|
GetDisabledReason: self.require(self.itemRangeSelected(self.canAmendRange)),
|
|
Description: self.c.Tr.AmendCommitAttribute,
|
|
Tooltip: self.c.Tr.AmendCommitAttributeTooltip,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.RevertCommit),
|
|
Handler: self.withItemsRange(self.revert),
|
|
GetDisabledReason: self.require(self.itemRangeSelected()),
|
|
Description: self.c.Tr.Revert,
|
|
Tooltip: self.c.Tr.RevertCommitTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.CreateTag),
|
|
Handler: self.withItem(self.createTag),
|
|
GetDisabledReason: self.require(self.singleItemSelected()),
|
|
Description: self.c.Tr.TagCommit,
|
|
Tooltip: self.c.Tr.TagCommitTooltip,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.OpenLogMenu),
|
|
Handler: self.handleOpenLogMenu,
|
|
Description: self.c.Tr.OpenLogMenu,
|
|
Tooltip: self.c.Tr.OpenLogMenuTooltip,
|
|
OpensMenu: true,
|
|
},
|
|
{
|
|
Keys: opts.GetKeys(opts.Config.Commits.OpenPullRequestInBrowser),
|
|
Handler: self.openPRInBrowser,
|
|
GetDisabledReason: self.checkedOutBranchHasPR,
|
|
Description: self.c.Tr.OpenPullRequestInBrowser,
|
|
},
|
|
}
|
|
|
|
return bindings
|
|
}
|
|
|
|
func (self *LocalCommitsController) checkedOutBranchHasPR() *types.DisabledReason {
|
|
branch := self.c.Model().CheckedOutBranch
|
|
if _, ok := self.c.Model().PullRequestsMap[branch]; !ok {
|
|
return &types.DisabledReason{Text: self.c.Tr.NoPullRequestForBranch, ShowErrorInPanel: true}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) openPRInBrowser() error {
|
|
pr, ok := self.c.Model().PullRequestsMap[self.c.Model().CheckedOutBranch]
|
|
if !ok {
|
|
// Should be guarded against by the DisabledReason check, but be defensive in case
|
|
// PullRequestsMap was updated concurrently by a background refresh
|
|
return errors.New(self.c.Tr.NoPullRequestForBranch)
|
|
}
|
|
|
|
self.c.LogAction(self.c.Tr.Actions.OpenPullRequest)
|
|
|
|
return self.c.OS().OpenLink(pr.Url)
|
|
}
|
|
|
|
func (self *LocalCommitsController) GetOnRenderToMain() func() {
|
|
return func() {
|
|
self.c.Helpers().Diff.WithDiffModeCheck(func() {
|
|
var task types.UpdateTask
|
|
commit := self.context().GetSelected()
|
|
if commit == nil {
|
|
task = types.NewRenderStringTask(self.c.Tr.NoCommitsThisBranch)
|
|
} else if commit.Action == todo.UpdateRef {
|
|
task = types.NewRenderStringTask(
|
|
utils.ResolvePlaceholderString(
|
|
self.c.Tr.UpdateRefHere,
|
|
map[string]string{
|
|
"ref": strings.TrimPrefix(commit.Name, "refs/heads/"),
|
|
}))
|
|
} else if commit.Action == todo.Exec {
|
|
task = types.NewRenderStringTask(
|
|
self.c.Tr.ExecCommandHere + "\n\n" + commit.Name)
|
|
} else {
|
|
refRange := self.context().GetSelectedRefRangeForDiffFiles()
|
|
task = self.c.Helpers().Diff.GetUpdateTaskForRenderingCommitsDiff(commit, refRange)
|
|
}
|
|
|
|
self.c.RenderToMainViews(types.RefreshMainOpts{
|
|
Pair: self.c.MainViewPairs().Normal,
|
|
Main: &types.ViewUpdateOpts{
|
|
Title: "Patch",
|
|
SubTitle: self.c.Helpers().Diff.IgnoringWhitespaceSubTitle(),
|
|
Task: task,
|
|
},
|
|
Secondary: secondaryPatchPanelUpdateOpts(self.c),
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
// secondaryPatchPanelUpdateOpts renders the custom patch being built into the pane
|
|
// beside the diff it is being built from, as a diff of the two trees the patch is
|
|
// materialized into — so that it is shown by whatever renders the rest of the diffs, and
|
|
// so that its lines can be pointed at and taken back out of the patch.
|
|
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
|
|
if !c.Git().Patch.PatchBuilder.Active() {
|
|
return nil
|
|
}
|
|
|
|
// A render of the same patch reuses the trees; only a change to the patch writes them
|
|
// again.
|
|
if err := c.Git().Patch.EnsureCustomPatchDiffTrees(); err != nil {
|
|
c.Log.Error(err)
|
|
}
|
|
|
|
// The same mode as the diff beside it: both panes of the pair have to agree about
|
|
// whether what they show can be acted on.
|
|
mode := c.Helpers().DiffLine.MainViewDiffMode()
|
|
cmdObj := c.Git().Diff.CustomPatchDiffCmdObj(c.Git().Patch.PatchBuilder.TempDir(), mode)
|
|
|
|
return &types.ViewUpdateOpts{
|
|
Task: types.NewMainViewDiffTask(cmdObj.GetCmd(), mode),
|
|
Title: c.Tr.CustomPatch,
|
|
}
|
|
}
|
|
|
|
func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
|
if self.isRebasing() {
|
|
return self.updateTodos(todo.Squash, selectedCommits)
|
|
}
|
|
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.Squash,
|
|
Prompt: self.c.Tr.SureSquashThisCommit,
|
|
HandleConfirm: func() error {
|
|
commits := self.c.Model().Commits
|
|
self.selectRebaseResultCommit(startIdx)
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.SquashingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.SquashCommitDown)
|
|
return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx)
|
|
})
|
|
},
|
|
})
|
|
|
|
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.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.Fixup,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.Fixup,
|
|
Keys: menuKey('f'),
|
|
OnPress: func() error {
|
|
commits := self.c.Model().Commits
|
|
self.selectRebaseResultCommit(startIdx)
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.FixingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.FixupCommit)
|
|
return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx)
|
|
})
|
|
},
|
|
Tooltip: self.c.Tr.FixupTooltip,
|
|
},
|
|
{
|
|
Label: self.c.Tr.FixupKeepMessage,
|
|
Keys: menuKey('c'),
|
|
OnPress: func() error {
|
|
commits := self.c.Model().Commits
|
|
self.selectRebaseResultCommit(startIdx)
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.FixingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage)
|
|
return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C")
|
|
})
|
|
},
|
|
Tooltip: self.c.Tr.FixupKeepMessageTooltip,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) canSetFixupMessage(commit *models.Commit) *types.DisabledReason {
|
|
if !self.isRebasing() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NotMidRebase}
|
|
}
|
|
|
|
if commit.Action != todo.Fixup {
|
|
return &types.DisabledReason{Text: self.c.Tr.MustSelectFixupCommit}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) setFixupMessage(commit *models.Commit) error {
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.SetFixupMessage,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.FixupDiscardMessage,
|
|
Keys: menuKey('f'),
|
|
OnPress: func() error {
|
|
return self.updateTodosWithFlag(todo.Fixup, []*models.Commit{commit}, "")
|
|
},
|
|
Tooltip: self.c.Tr.FixupDiscardMessageTooltip,
|
|
},
|
|
{
|
|
Label: self.c.Tr.FixupKeepMessage,
|
|
Keys: menuKey('c'),
|
|
OnPress: func() error {
|
|
return self.updateTodosWithFlag(todo.Fixup, []*models.Commit{commit}, "-C")
|
|
},
|
|
Tooltip: self.c.Tr.FixupKeepMessageTooltip,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) reword(commit *models.Commit) error {
|
|
commitIdx := self.context().GetSelectedLineIdx()
|
|
if self.c.Git().Config.NeedsGpgSubprocessForCommit() && !self.isHeadCommit(commitIdx) {
|
|
return errors.New(self.c.Tr.DisabledForGPG)
|
|
}
|
|
commitMessage, err := self.c.Git().Commit.GetCommitMessage(commit.Hash())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage {
|
|
commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth)
|
|
}
|
|
self.c.Helpers().Commits.OpenCommitMessagePanel(
|
|
&helpers.OpenCommitMessagePanelOpts{
|
|
CommitIndex: commitIdx,
|
|
InitialMessage: commitMessage,
|
|
SummaryTitle: self.c.Tr.Actions.RewordCommit,
|
|
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
|
|
PreserveMessage: false,
|
|
OnConfirm: self.handleReword,
|
|
OnSwitchToEditor: self.switchFromCommitMessagePanelToEditor,
|
|
},
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) switchFromCommitMessagePanelToEditor(filepath string) error {
|
|
if self.isSelectedHeadCommit() {
|
|
return self.c.RunSubprocessAndRefresh(
|
|
self.c.Git().Commit.RewordLastCommitInEditorWithMessageFileCmdObj(filepath))
|
|
}
|
|
|
|
err := self.c.Git().Rebase.BeginInteractiveRebaseForCommit(self.c.Model().Commits, self.context().GetSelectedLineIdx(), false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// now the selected commit should be our head so we'll amend it with the new message
|
|
err = self.c.RunSubprocessAndRefresh(
|
|
self.c.Git().Commit.RewordLastCommitInEditorWithMessageFileCmdObj(filepath))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = self.c.Git().Rebase.ContinueRebase()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{})
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) handleReword(summary string, description string) error {
|
|
commits := self.c.Model().Commits
|
|
selectedIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx()
|
|
if models.IsHeadCommit(commits, selectedIdx) {
|
|
// we've selected the top commit so no rebase is required
|
|
return self.c.Helpers().GPG.WithGpgHandling(self.c.Git().Commit.RewordLastCommit(summary, description),
|
|
git_commands.CommitGpgSign,
|
|
self.c.Tr.RewordingStatus, nil, nil)
|
|
}
|
|
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.RewordingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
self.c.RefreshFromWorker(types.RefreshOptions{})
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) doRewordEditor() error {
|
|
self.c.LogAction(self.c.Tr.Actions.RewordCommit)
|
|
|
|
if self.isSelectedHeadCommit() {
|
|
return self.c.RunSubprocessAndRefresh(self.c.Git().Commit.RewordLastCommitInEditorCmdObj())
|
|
}
|
|
|
|
subProcess, err := self.c.Git().Rebase.RewordCommitInEditor(
|
|
self.c.Model().Commits, self.context().GetSelectedLineIdx(),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if subProcess != nil {
|
|
return self.c.RunSubprocessAndRefresh(subProcess)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) rewordEditor(commit *models.Commit) error {
|
|
return self.c.ConfirmIf(!self.c.UserConfig().Gui.SkipRewordInEditorWarning,
|
|
types.ConfirmOpts{
|
|
Title: self.c.Tr.RewordInEditorTitle,
|
|
Prompt: self.c.Tr.RewordInEditorPrompt,
|
|
HandleConfirm: self.doRewordEditor,
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
|
if self.isRebasing() {
|
|
groupedTodos := lo.GroupBy(selectedCommits, func(c *models.Commit) bool {
|
|
return c.Action == todo.UpdateRef
|
|
})
|
|
updateRefTodos := groupedTodos[true]
|
|
nonUpdateRefTodos := groupedTodos[false]
|
|
|
|
if len(updateRefTodos) > 0 {
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.DropCommitTitle,
|
|
Prompt: self.c.Tr.DropUpdateRefPrompt,
|
|
HandleConfirm: func() error {
|
|
selectedIdx, rangeStartIdx, rangeSelectMode := self.context().GetSelectionRangeAndMode()
|
|
|
|
if err := self.c.Git().Rebase.DeleteUpdateRefTodos(updateRefTodos); err != nil {
|
|
return err
|
|
}
|
|
|
|
if selectedIdx > rangeStartIdx {
|
|
selectedIdx = max(selectedIdx-len(updateRefTodos), rangeStartIdx)
|
|
} else {
|
|
rangeStartIdx = max(rangeStartIdx-len(updateRefTodos), selectedIdx)
|
|
}
|
|
|
|
self.context().SetSelectionRangeAndMode(selectedIdx, rangeStartIdx, rangeSelectMode)
|
|
|
|
return self.updateTodos(todo.Drop, nonUpdateRefTodos)
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
return self.updateTodos(todo.Drop, selectedCommits)
|
|
}
|
|
|
|
isMerge := selectedCommits[0].IsMerge()
|
|
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.DropCommitTitle,
|
|
Prompt: lo.Ternary(isMerge, self.c.Tr.DropMergeCommitPrompt, self.c.Tr.DropCommitPrompt),
|
|
HandleConfirm: func() error {
|
|
commits := self.c.Model().Commits
|
|
if !isMerge {
|
|
self.selectRebaseResultCommit(startIdx)
|
|
}
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.DroppingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.DropCommit)
|
|
if isMerge {
|
|
return self.dropMergeCommit(commits, startIdx)
|
|
}
|
|
return self.interactiveRebase(commits, todo.Drop, startIdx, endIdx)
|
|
})
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) dropMergeCommit(commits []*models.Commit, commitIdx int) error {
|
|
err := self.c.Git().Rebase.DropMergeCommit(commits, commitIdx)
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
|
}
|
|
|
|
func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
|
if self.isRebasing() {
|
|
return self.updateTodos(todo.Edit, selectedCommits)
|
|
}
|
|
|
|
commits := self.c.Model().Commits
|
|
if !commits[endIdx].IsMerge() {
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.RebasingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "")
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
|
err, types.RefreshOptions{BatchUIUpdates: true})
|
|
})
|
|
}
|
|
|
|
return self.startInteractiveRebaseWithEdit(selectedCommits)
|
|
}
|
|
|
|
func (self *LocalCommitsController) quickStartInteractiveRebase() error {
|
|
commitToEdit, err := self.findCommitForQuickStartInteractiveRebase()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.startInteractiveRebaseWithEdit([]*models.Commit{commitToEdit})
|
|
}
|
|
|
|
func (self *LocalCommitsController) startInteractiveRebaseWithEdit(
|
|
commitsToEdit []*models.Commit,
|
|
) error {
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.RebasingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.EditCommit)
|
|
err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash())
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
|
err,
|
|
types.RefreshOptions{BatchUIUpdates: true, Then: func() error {
|
|
todos := make([]*models.Commit, 0, len(commitsToEdit)-1)
|
|
for _, c := range commitsToEdit[:len(commitsToEdit)-1] {
|
|
// Merge commits can't be set to "edit", so just skip them
|
|
if !c.IsMerge() {
|
|
todos = append(todos, models.NewCommit(self.c.Model().HashPool, models.NewCommitOpts{Hash: c.Hash(), Action: todo.Pick}))
|
|
}
|
|
}
|
|
if len(todos) > 0 {
|
|
return self.updateTodos(todo.Edit, todos)
|
|
}
|
|
return nil
|
|
}})
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) findCommitForQuickStartInteractiveRebase() (*models.Commit, error) {
|
|
commit, index, ok := lo.FindIndexOf(self.c.Model().Commits, func(c *models.Commit) bool {
|
|
return c.IsMerge() || c.Status == models.StatusMerged
|
|
})
|
|
|
|
if !ok || index == 0 {
|
|
errorMsg := utils.ResolvePlaceholderString(self.c.Tr.CannotQuickStartInteractiveRebase, map[string]string{
|
|
"editKey": self.c.UserConfig().Keybinding.Universal.Edit.String(),
|
|
})
|
|
|
|
return nil, errors.New(errorMsg)
|
|
}
|
|
|
|
return commit, nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) pick(selectedCommits []*models.Commit) error {
|
|
if self.isRebasing() {
|
|
return self.updateTodos(todo.Pick, selectedCommits)
|
|
}
|
|
|
|
panic("should be disabled when not rebasing")
|
|
}
|
|
|
|
func (self *LocalCommitsController) interactiveRebase(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int) error {
|
|
return self.interactiveRebaseWithFlag(commits, action, startIdx, endIdx, "")
|
|
}
|
|
|
|
func (self *LocalCommitsController) interactiveRebaseWithFlag(commits []*models.Commit, action todo.TodoCommand, startIdx int, endIdx int, flag string) error {
|
|
err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, action, flag)
|
|
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
|
}
|
|
|
|
// selectRebaseResultCommit selects the commit that a drop/fixup/squash starting
|
|
// at startIdx will leave there. It must run on the UI thread before the rebase:
|
|
// the commit currently at startIdx is removed, so the refresh's
|
|
// keep-selection-by-hash can't restore it and falls back to the index, which by
|
|
// then holds the commit that shifted up into its place.
|
|
func (self *LocalCommitsController) selectRebaseResultCommit(startIdx int) {
|
|
self.context().SetSelection(startIdx)
|
|
}
|
|
|
|
// 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) updateTodos(action todo.TodoCommand, selectedCommits []*models.Commit) error {
|
|
return self.updateTodosWithFlag(action, selectedCommits, "")
|
|
}
|
|
|
|
func (self *LocalCommitsController) updateTodosWithFlag(action todo.TodoCommand, selectedCommits []*models.Commit, flag string) error {
|
|
if err := self.c.Git().Rebase.EditRebaseTodo(selectedCommits, action, flag); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.Refresh(types.RefreshOptions{
|
|
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
|
})
|
|
|
|
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.isSelectedHeadCommit() {
|
|
return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) isRebasing() bool {
|
|
return self.c.Model().WorkingTreeStateAtLastCommitRefresh.Any()
|
|
}
|
|
|
|
func (self *LocalCommitsController) isCherryPickingOrReverting() bool {
|
|
return self.c.Model().WorkingTreeStateAtLastCommitRefresh.CherryPicking ||
|
|
self.c.Model().WorkingTreeStateAtLastCommitRefresh.Reverting
|
|
}
|
|
|
|
func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
|
return self.move(selectedCommits, startIdx, endIdx, 1, nil)
|
|
}
|
|
|
|
func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
|
|
return self.move(selectedCommits, startIdx, endIdx, -1, nil)
|
|
}
|
|
|
|
func (self *LocalCommitsController) move(
|
|
selectedCommits []*models.Commit, startIdx int, endIdx int, offset int, onComplete func() error,
|
|
) error {
|
|
if self.isRebasing() {
|
|
if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil {
|
|
return err
|
|
}
|
|
self.context().MoveSelection(offset)
|
|
self.context().HandleFocus(types.OnFocusOpts{})
|
|
|
|
// Block input until the refresh has landed: a quick second press must
|
|
// read the moved todo from the refreshed model, not grab whatever the
|
|
// advanced selection index points at in the stale one.
|
|
self.c.RefreshBlockingInput(types.RefreshOptions{
|
|
Scope: []types.RefreshableView{types.REBASE_COMMITS},
|
|
CommitSelection: types.KeepCommitSelectionIndex,
|
|
Then: onComplete,
|
|
})
|
|
return nil
|
|
}
|
|
|
|
commits := self.c.Model().Commits
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.MovingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
if offset > 0 {
|
|
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
|
|
} else {
|
|
self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
|
|
}
|
|
err := self.c.Git().Rebase.MoveCommits(commits, startIdx, endIdx, offset)
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
|
err, types.RefreshOptions{
|
|
BatchUIUpdates: true,
|
|
CommitSelection: types.KeepCommitSelectionIndex,
|
|
// Move the selection to follow the moved commit, in Then so it
|
|
// lands in the same frame as the refreshed commit list.
|
|
Then: func() error {
|
|
if err == nil {
|
|
self.context().MoveSelection(offset)
|
|
self.context().HandleFocus(types.OnFocusOpts{})
|
|
}
|
|
if onComplete != nil {
|
|
return onComplete()
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) amendTo(commit *models.Commit) error {
|
|
var handleCommit func() error
|
|
|
|
if self.isSelectedHeadCommit() {
|
|
handleCommit = func() error {
|
|
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
|
|
if err := self.c.Helpers().AmendHelper.AmendHead(); err != nil {
|
|
return err
|
|
}
|
|
self.c.Refresh(types.RefreshOptions{})
|
|
return nil
|
|
})
|
|
}
|
|
} else {
|
|
commits := self.c.Model().Commits
|
|
selectedIdx := self.context().GetView().SelectedLineIdx()
|
|
handleCommit = func() error {
|
|
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.AmendingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.AmendCommit)
|
|
err := self.c.Git().Rebase.AmendTo(commits, selectedIdx)
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
return self.c.ConfirmIf(!self.c.UserConfig().Gui.SkipAmendWarning,
|
|
types.ConfirmOpts{
|
|
Title: self.c.Tr.AmendCommitTitle,
|
|
Prompt: self.c.Tr.AmendCommitPrompt,
|
|
HandleConfirm: handleCommit,
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) canAmendRange(commits []*models.Commit, start, end int) *types.DisabledReason {
|
|
if (start != end || !self.isHeadCommit(start)) && self.isRebasing() {
|
|
return &types.DisabledReason{Text: self.c.Tr.AlreadyRebasing}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) canAmend(_ *models.Commit) *types.DisabledReason {
|
|
idx := self.context().GetSelectedLineIdx()
|
|
return self.canAmendRange(self.c.Model().Commits, idx, idx)
|
|
}
|
|
|
|
func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, end int) error {
|
|
// The author operations index into the full commit list by absolute
|
|
// start/end, so capture that here on the UI thread rather than reading
|
|
// Model().Commits from the worker the menu items dispatch to.
|
|
commits := self.c.Model().Commits
|
|
opts := self.c.KeybindingsOpts()
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: "Amend commit attribute",
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.ResetAuthor,
|
|
OnPress: func() error { return self.resetAuthor(commits, start, end) },
|
|
Keys: opts.GetKeys(opts.Config.AmendAttribute.ResetAuthor),
|
|
Tooltip: self.c.Tr.ResetAuthorTooltip,
|
|
},
|
|
{
|
|
Label: self.c.Tr.SetAuthor,
|
|
OnPress: func() error { return self.setAuthor(commits, start, end) },
|
|
Keys: opts.GetKeys(opts.Config.AmendAttribute.SetAuthor),
|
|
Tooltip: self.c.Tr.SetAuthorTooltip,
|
|
},
|
|
{
|
|
Label: self.c.Tr.AddCoAuthor,
|
|
OnPress: func() error { return self.addCoAuthor(commits, start, end) },
|
|
Keys: opts.GetKeys(opts.Config.AmendAttribute.AddCoAuthor),
|
|
Tooltip: self.c.Tr.AddCoAuthorTooltip,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error {
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.AmendingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor)
|
|
if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.RefreshFromWorker(types.RefreshOptions{})
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, end int) error {
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: self.c.Tr.SetAuthorPromptTitle,
|
|
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
|
|
HandleConfirm: func(value string) error {
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.AmendingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor)
|
|
if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.RefreshFromWorker(types.RefreshOptions{})
|
|
return nil
|
|
})
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, end int) error {
|
|
self.c.Prompt(types.PromptOpts{
|
|
Title: self.c.Tr.AddCoAuthorPromptTitle,
|
|
FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(),
|
|
HandleConfirm: func(value string) error {
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.AmendingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor)
|
|
if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil {
|
|
return err
|
|
}
|
|
self.c.RefreshFromWorker(types.RefreshOptions{})
|
|
return nil
|
|
})
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) revert(commits []*models.Commit, start, end int) error {
|
|
var promptText string
|
|
if len(commits) == 1 {
|
|
promptText = utils.ResolvePlaceholderString(
|
|
self.c.Tr.ConfirmRevertCommit,
|
|
map[string]string{
|
|
"selectedCommit": commits[0].ShortHash(),
|
|
})
|
|
} else {
|
|
promptText = self.c.Tr.ConfirmRevertCommitRange
|
|
}
|
|
hashes := lo.Map(commits, func(c *models.Commit, _ int) string { return c.Hash() })
|
|
isMerge := lo.SomeBy(commits, func(c *models.Commit) bool { return c.IsMerge() })
|
|
|
|
self.c.Confirm(types.ConfirmOpts{
|
|
Title: self.c.Tr.Actions.RevertCommit,
|
|
Prompt: promptText,
|
|
HandleConfirm: func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.RevertCommit)
|
|
mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules)
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.RevertingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
if mustStash {
|
|
if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
result := self.c.Git().Commit.Revert(hashes, isMerge)
|
|
if err := self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(result,
|
|
types.RefreshOptions{BatchUIUpdates: true}); err != nil {
|
|
return err
|
|
}
|
|
|
|
if mustStash {
|
|
if err := self.c.Git().Stash.Pop(0); err != nil {
|
|
return err
|
|
}
|
|
self.c.RefreshFromWorker(types.RefreshOptions{
|
|
Scope: []types.RefreshableView{types.STASH, types.FILES},
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
},
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) error {
|
|
var disabledReasonWhenFilesAreNeeded *types.DisabledReason
|
|
if len(self.c.Model().Files) == 0 {
|
|
disabledReasonWhenFilesAreNeeded = &types.DisabledReason{
|
|
Text: self.c.Tr.NoFilesStagedTitle,
|
|
ShowErrorInPanel: true,
|
|
}
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.CreateFixupCommit,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.FixupMenu_Fixup,
|
|
Keys: menuKey('f'),
|
|
OnPress: func() error {
|
|
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
|
|
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
|
|
selectedIdx := self.context().GetSelectedLineIdx()
|
|
commits := self.c.Model().Commits
|
|
branches := self.c.Model().Branches
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.CreatingFixupCommitStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true})
|
|
return nil
|
|
})
|
|
})
|
|
},
|
|
DisabledReason: disabledReasonWhenFilesAreNeeded,
|
|
Tooltip: self.c.Tr.FixupMenu_FixupTooltip,
|
|
},
|
|
{
|
|
Label: self.c.Tr.FixupMenu_AmendWithChanges,
|
|
Keys: menuKey('a'),
|
|
OnPress: func() error {
|
|
return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error {
|
|
return self.createAmendCommit(commit, true)
|
|
})
|
|
},
|
|
DisabledReason: disabledReasonWhenFilesAreNeeded,
|
|
Tooltip: self.c.Tr.FixupMenu_AmendWithChangesTooltip,
|
|
},
|
|
{
|
|
Label: self.c.Tr.FixupMenu_AmendWithoutChanges,
|
|
Keys: menuKey('r'),
|
|
OnPress: func() error { return self.createAmendCommit(commit, false) },
|
|
Tooltip: self.c.Tr.FixupMenu_AmendWithoutChangesTooltip,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// moveFixupCommitToOwnerStackedBranch takes state captured on the UI thread
|
|
// (the selected index and the commits and branches models) so that it can run
|
|
// its rebase on a worker without reading the model there.
|
|
func (self *LocalCommitsController) moveFixupCommitToOwnerStackedBranch(
|
|
targetCommit *models.Commit, selectedIdx int, commits []*models.Commit, branches []*models.Branch,
|
|
) error {
|
|
if self.c.Git().Version.IsOlderThan(2, 38, 0) {
|
|
// Git 2.38.0 introduced the `rebase.updateRefs` config option. Don't
|
|
// move the commit down with older versions, as it would break the stack.
|
|
return nil
|
|
}
|
|
|
|
if self.c.Git().Status.WorkingTreeState().Any() {
|
|
// Can't move commits while rebasing
|
|
return nil
|
|
}
|
|
|
|
if targetCommit.Status == models.StatusMerged {
|
|
// Target commit is already on main. It's a bit questionable that we
|
|
// allow creating a fixup commit for it in the first place, but we
|
|
// always did, so why restrict that now; however, it doesn't make sense
|
|
// to move the created fixup commit down in that case.
|
|
return nil
|
|
}
|
|
|
|
if !self.c.Git().Config.GetRebaseUpdateRefs() {
|
|
// If the user has disabled rebase.updateRefs, we don't move the fixup
|
|
// because this would break the stack of branches (presumably they like
|
|
// to manage it themselves manually, or something).
|
|
return nil
|
|
}
|
|
|
|
headOfOwnerBranchIdx := -1
|
|
for i := selectedIdx; i > 0; i-- {
|
|
if lo.SomeBy(branches, func(b *models.Branch) bool {
|
|
return b.CommitHash == commits[i].Hash()
|
|
}) {
|
|
headOfOwnerBranchIdx = i
|
|
break
|
|
}
|
|
}
|
|
|
|
if headOfOwnerBranchIdx == -1 {
|
|
return nil
|
|
}
|
|
|
|
return self.c.Git().Rebase.MoveFixupCommitDown(commits, headOfOwnerBranchIdx)
|
|
}
|
|
|
|
func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, includeFileChanges bool) error {
|
|
commitMessage, err := self.c.Git().Commit.GetCommitMessage(commit.Hash())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if self.c.UserConfig().Git.Commit.AutoWrapCommitMessage {
|
|
commitMessage = helpers.TryRemoveHardLineBreaks(commitMessage, self.c.UserConfig().Git.Commit.AutoWrapWidth)
|
|
}
|
|
originalSubject, _, _ := strings.Cut(commitMessage, "\n")
|
|
self.c.Helpers().Commits.OpenCommitMessagePanel(
|
|
&helpers.OpenCommitMessagePanelOpts{
|
|
CommitIndex: self.context().GetSelectedLineIdx(),
|
|
InitialMessage: commitMessage,
|
|
SummaryTitle: self.c.Tr.CreateAmendCommit,
|
|
DescriptionTitle: self.c.Tr.CommitDescriptionTitle,
|
|
PreserveMessage: false,
|
|
OnConfirm: func(summary string, description string) error {
|
|
self.c.LogAction(self.c.Tr.Actions.CreateFixupCommit)
|
|
selectedIdx := self.context().GetSelectedLineIdx()
|
|
commits := self.c.Model().Commits
|
|
branches := self.c.Model().Branches
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.CreatingFixupCommitStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := self.moveFixupCommitToOwnerStackedBranch(commit, selectedIdx, commits, branches); err != nil {
|
|
return err
|
|
}
|
|
|
|
self.c.RefreshFromWorker(types.RefreshOptions{BatchUIUpdates: true})
|
|
return nil
|
|
})
|
|
},
|
|
OnSwitchToEditor: nil,
|
|
},
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) squashFixupCommits() error {
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.SquashAboveCommits,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.SquashCommitsInCurrentBranch,
|
|
OnPress: self.squashAllFixupsInCurrentBranch,
|
|
DisabledReason: self.canFindCommitForSquashFixupsInCurrentBranch(),
|
|
Keys: menuKey('b'),
|
|
Tooltip: self.c.Tr.SquashCommitsInCurrentBranchTooltip,
|
|
},
|
|
{
|
|
Label: self.c.Tr.SquashCommitsAboveSelectedCommit,
|
|
OnPress: self.withItem(self.squashAllFixupsAboveSelectedCommit),
|
|
DisabledReason: self.singleItemSelected()(),
|
|
Keys: menuKey('a'),
|
|
Tooltip: self.c.Tr.SquashCommitsAboveSelectedTooltip,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) squashAllFixupsAboveSelectedCommit(commit *models.Commit) error {
|
|
return self.squashFixupsImpl(commit, self.context().GetSelectedLineIdx())
|
|
}
|
|
|
|
func (self *LocalCommitsController) squashAllFixupsInCurrentBranch() error {
|
|
commit, rebaseStartIdx, err := self.findCommitForSquashFixupsInCurrentBranch()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return self.squashFixupsImpl(commit, rebaseStartIdx)
|
|
}
|
|
|
|
func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, rebaseStartIdx int) error {
|
|
selectionOffset := countSquashableCommitsAbove(self.c.Model().Commits, self.context().GetSelectedLineIdx(), rebaseStartIdx)
|
|
// The squashed fixups above the selection are removed, so the selection moves
|
|
// up by that many rows to stay on the same commit. Compute the target as an
|
|
// absolute index now, on the current list.
|
|
targetIdx := self.context().GetSelectedLineIdx() - selectionOffset
|
|
return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{
|
|
Message: self.c.Tr.SquashingStatus,
|
|
HideWorkingTreeState: true,
|
|
}, func(gocui.Task) error {
|
|
self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits)
|
|
err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit)
|
|
return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions(
|
|
err, types.RefreshOptions{
|
|
BatchUIUpdates: true,
|
|
// Set the selection in Then so it lands in the same frame as the
|
|
// refreshed commit list. It has to be an absolute index: the new
|
|
// list is shorter, so a relative move from the (clamped) old index
|
|
// could overshoot. PostRefreshUpdate repaints the moved selection.
|
|
Then: func() error {
|
|
if err == nil {
|
|
self.context().SetSelectedLineIdx(targetIdx)
|
|
self.c.PostRefreshUpdate(self.context())
|
|
}
|
|
return nil
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) findCommitForSquashFixupsInCurrentBranch() (*models.Commit, int, error) {
|
|
commits := self.c.Model().Commits
|
|
_, index, ok := lo.FindIndexOf(commits, func(c *models.Commit) bool {
|
|
return c.IsMerge() || c.Status == models.StatusMerged
|
|
})
|
|
|
|
if !ok || index == 0 {
|
|
return nil, -1, errors.New(self.c.Tr.CannotSquashCommitsInCurrentBranch)
|
|
}
|
|
|
|
return commits[index-1], index - 1, nil
|
|
}
|
|
|
|
// Anticipate how many commits above the selectedIdx are going to get squashed
|
|
// by the SquashAllAboveFixupCommits call, so that we can adjust the selection
|
|
// afterwards. Let's hope we're matching git's behavior correctly here.
|
|
func countSquashableCommitsAbove(commits []*models.Commit, selectedIdx int, rebaseStartIdx int) int {
|
|
result := 0
|
|
|
|
// For each commit _above_ the selection, ...
|
|
for i, commit := range commits[0:selectedIdx] {
|
|
// ... see if it is a fixup commit, and get the base subject it applies to
|
|
if baseSubject, isFixup := helpers.IsFixupCommit(commit.Name); isFixup {
|
|
// Then, for each commit after the fixup, up to and including the
|
|
// rebase start commit, see if we find the base commit
|
|
for _, baseCommit := range commits[i+1 : rebaseStartIdx+1] {
|
|
if strings.HasPrefix(baseCommit.Name, baseSubject) {
|
|
result++
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (self *LocalCommitsController) createTag(commit *models.Commit) error {
|
|
return self.c.Helpers().Tags.OpenCreateTagPrompt(commit.Hash(), func() {})
|
|
}
|
|
|
|
func (self *LocalCommitsController) openSearch() error {
|
|
// we usually lazyload these commits but now that we're searching we need to load them now
|
|
if self.context().GetLimitCommits() {
|
|
self.context().SetLimitCommits(false)
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
|
|
}
|
|
|
|
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
|
|
}
|
|
|
|
func (self *LocalCommitsController) handleOpenLogMenu() error {
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.LogMenuTitle,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: self.c.Tr.ToggleShowGitGraphAll,
|
|
OnPress: func() error {
|
|
self.context().SetShowWholeGitGraph(!self.context().GetShowWholeGitGraph())
|
|
|
|
if self.context().GetShowWholeGitGraph() {
|
|
self.context().SetLimitCommits(false)
|
|
}
|
|
|
|
return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error {
|
|
self.c.Refresh(
|
|
types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}},
|
|
)
|
|
return nil
|
|
})
|
|
},
|
|
},
|
|
{
|
|
Label: self.c.Tr.ShowGitGraph,
|
|
Tooltip: self.c.Tr.ShowGitGraphTooltip,
|
|
OpensMenu: true,
|
|
OnPress: func() error {
|
|
currentValue := self.c.UserConfig().Git.Log.ShowGraph
|
|
onPress := func(value string) func() error {
|
|
return func() error {
|
|
self.c.UserConfig().Git.Log.ShowGraph = value
|
|
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
|
|
self.c.PostRefreshUpdate(self.c.Contexts().SubCommits)
|
|
return nil
|
|
}
|
|
}
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.LogMenuTitle,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: "always",
|
|
OnPress: onPress("always"),
|
|
Widget: types.MakeMenuRadioButton(currentValue == "always"),
|
|
},
|
|
{
|
|
Label: "never",
|
|
OnPress: onPress("never"),
|
|
Widget: types.MakeMenuRadioButton(currentValue == "never"),
|
|
},
|
|
{
|
|
Label: "when maximised",
|
|
OnPress: onPress("when-maximised"),
|
|
Widget: types.MakeMenuRadioButton(currentValue == "when-maximised"),
|
|
},
|
|
},
|
|
})
|
|
},
|
|
},
|
|
{
|
|
Label: self.c.Tr.SortCommits,
|
|
Tooltip: self.c.Tr.SortCommitsTooltip,
|
|
OpensMenu: true,
|
|
OnPress: func() error {
|
|
currentValue := self.c.UserConfig().Git.Log.Order
|
|
onPress := func(value string) func() error {
|
|
return func() error {
|
|
self.c.UserConfig().Git.Log.Order = value
|
|
return self.c.WithWaitingStatus(self.c.Tr.LoadingCommits, func(gocui.Task) error {
|
|
self.c.Refresh(
|
|
types.RefreshOptions{
|
|
Scope: []types.RefreshableView{types.COMMITS},
|
|
},
|
|
)
|
|
return nil
|
|
})
|
|
}
|
|
}
|
|
|
|
return self.c.Menu(types.CreateMenuOptions{
|
|
Title: self.c.Tr.LogMenuTitle,
|
|
Items: []*types.MenuItem{
|
|
{
|
|
Label: "topological (topo-order)",
|
|
OnPress: onPress("topo-order"),
|
|
Widget: types.MakeMenuRadioButton(currentValue == "topo-order"),
|
|
},
|
|
{
|
|
Label: "date-order",
|
|
OnPress: onPress("date-order"),
|
|
Widget: types.MakeMenuRadioButton(currentValue == "date-order"),
|
|
},
|
|
{
|
|
Label: "author-date-order",
|
|
OnPress: onPress("author-date-order"),
|
|
Widget: types.MakeMenuRadioButton(currentValue == "author-date-order"),
|
|
},
|
|
{
|
|
Label: "default",
|
|
OnPress: onPress("default"),
|
|
Widget: types.MakeMenuRadioButton(currentValue == "default"),
|
|
},
|
|
},
|
|
})
|
|
},
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func (self *LocalCommitsController) GetOnFocus() func(types.OnFocusOpts) {
|
|
return func(types.OnFocusOpts) {
|
|
context := self.context()
|
|
if context.GetSelectedLineIdx() > COMMIT_THRESHOLD && context.GetLimitCommits() {
|
|
context.SetLimitCommits(false)
|
|
self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.COMMITS}})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (self *LocalCommitsController) context() *context.LocalCommitsContext {
|
|
return self.c.Contexts().LocalCommits
|
|
}
|
|
|
|
func (self *LocalCommitsController) paste() error {
|
|
return self.c.Helpers().CherryPick.Paste()
|
|
}
|
|
|
|
func (self *LocalCommitsController) canPaste() *types.DisabledReason {
|
|
if !self.c.Helpers().CherryPick.CanPaste() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NoCopiedCommits}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) markAsBaseCommit(commit *models.Commit) error {
|
|
if commit.Hash() == self.c.Modes().MarkedBaseCommit.GetHash() {
|
|
// Reset when invoking it again on the marked commit
|
|
self.c.Modes().MarkedBaseCommit.SetHash("")
|
|
} else {
|
|
self.c.Modes().MarkedBaseCommit.SetHash(commit.Hash())
|
|
}
|
|
self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits)
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) isHeadCommit(idx int) bool {
|
|
return models.IsHeadCommit(self.c.Model().Commits, idx)
|
|
}
|
|
|
|
func (self *LocalCommitsController) isSelectedHeadCommit() bool {
|
|
return self.isHeadCommit(self.context().GetSelectedLineIdx())
|
|
}
|
|
|
|
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 nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) canFindCommitForSquashFixupsInCurrentBranch() *types.DisabledReason {
|
|
if _, _, err := self.findCommitForSquashFixupsInCurrentBranch(); err != nil {
|
|
return &types.DisabledReason{Text: err.Error()}
|
|
}
|
|
|
|
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}
|
|
}
|
|
|
|
if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
|
|
return &types.DisabledReason{Text: self.c.Tr.CannotSquashOrFixupMergeCommit}
|
|
}
|
|
|
|
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].Status == models.StatusConflicted {
|
|
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].Status == models.StatusConflicted {
|
|
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.isCherryPickingOrReverting() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
|
|
}
|
|
|
|
if !self.isRebasing() {
|
|
return nil
|
|
}
|
|
|
|
for _, commit := range selectedCommits {
|
|
if !commit.IsTODO() {
|
|
return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits}
|
|
}
|
|
|
|
if !isChangeOfRebaseTodoAllowed(commit.Action) {
|
|
return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Ensures that if we are mid-rebase, we're only selecting commits that can be moved
|
|
func (self *LocalCommitsController) midRebaseMoveCommandEnabled(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
|
|
if self.isCherryPickingOrReverting() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
|
|
}
|
|
|
|
if !self.isRebasing() {
|
|
if lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
|
|
return &types.DisabledReason{Text: self.c.Tr.CannotMoveMergeCommit}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
for _, commit := range selectedCommits {
|
|
if !commit.IsTODO() {
|
|
return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits}
|
|
}
|
|
|
|
// All todo types that can be edited are allowed to be moved, plus
|
|
// update-ref todos
|
|
if !isChangeOfRebaseTodoAllowed(commit.Action) && commit.Action != todo.UpdateRef {
|
|
return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (self *LocalCommitsController) canDropCommits(selectedCommits []*models.Commit, startIdx int, endIdx int) *types.DisabledReason {
|
|
if self.isCherryPickingOrReverting() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
|
|
}
|
|
|
|
if !self.isRebasing() {
|
|
if len(selectedCommits) > 1 && lo.SomeBy(selectedCommits, func(c *models.Commit) bool { return c.IsMerge() }) {
|
|
return &types.DisabledReason{Text: self.c.Tr.DroppingMergeRequiresSingleSelection}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
nonUpdateRefTodos := lo.Filter(selectedCommits, func(c *models.Commit, _ int) bool {
|
|
return c.Action != todo.UpdateRef
|
|
})
|
|
|
|
for _, commit := range nonUpdateRefTodos {
|
|
if !commit.IsTODO() {
|
|
return &types.DisabledReason{Text: self.c.Tr.MustSelectTodoCommits}
|
|
}
|
|
|
|
if !isChangeOfRebaseTodoAllowed(commit.Action) {
|
|
return &types.DisabledReason{Text: self.c.Tr.ChangingThisActionIsNotAllowed}
|
|
}
|
|
}
|
|
|
|
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.isCherryPickingOrReverting() {
|
|
return &types.DisabledReason{Text: self.c.Tr.NotAllowedMidCherryPickOrRevert}
|
|
}
|
|
|
|
if !self.isRebasing() {
|
|
return &types.DisabledReason{Text: self.c.Tr.PickIsOnlyAllowedDuringRebase, AllowFurtherDispatching: true}
|
|
}
|
|
|
|
return self.midRebaseCommandEnabled(selectedCommits, startIdx, endIdx)
|
|
}
|