Files
lazygit/pkg/gui/controllers/local_commits_controller.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)
}