Files
lazygit/pkg/gui/controllers/local_commits_controller.go
Stefan HallerandClaude Opus 5 aebf495dce Scroll the selection into view by default
Ever since scrolling the selection into view became opt-in, we have been
fixing the same class of regression by hand, five times so far: a
controller moves the selection somewhere new, doesn't say that it wants
the view to follow, and the selection ends up off screen. The decision
needs facts from two places — whether the selection went somewhere new is
known to the list, whether the scroll position is the caller's to manage
is known to the caller — and asking every caller for both is what keeps
going wrong. The callers that get it wrong are usually not even the ones
that moved the selection: they are pass-throughs like postRefreshUpdate,
which can't know what a refresh did to the selection.

So default to scrolling, and let the two callers that maintain the scroll
position themselves say so.

The one case where scrolling is always wrong is a refresh that no user
action is behind: a background poll, or a reload of state on window
focus, after a subprocess, or after a repo switch. Those must leave the
viewport wherever the user last scrolled it to — that is what made the
scrolling opt-in in the first place. Both are already marked in
RefreshOptions, so the refresh can decide it once, centrally, instead of
each caller judging it.

A user action that ends in a foreground refresh does now yank the view
back to the selection if the user had scrolled away from it. That's a
behaviour change, and there may be actions where it turns out to be
unwelcome; those we can fix individually, and it beats the ones that
don't scroll today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 08:04:13 +02:00

2016 lines
66 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.PostRefreshUpdate(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),
})
})
}
}
func secondaryPatchPanelUpdateOpts(c *ControllerCommon) *types.ViewUpdateOpts {
if c.Git().Patch.PatchBuilder.Active() {
patch := c.Git().Patch.PatchBuilder.RenderAggregatedPatch(false)
return &types.ViewUpdateOpts{
Task: types.NewRenderStringWithoutScrollTask(patch),
Title: c.Tr.CustomPatch,
}
}
return nil
}
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)
}