/
githubmirror
/
lazygit
Обзор
Документация
Войти
/
githubmirror
/
lazygit
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
pkg/gui/controllers/local_commits_controller.go
2 015 строк
66 KB
Stefan Haller
Decouple hiding the working tree state from blocking input
05 авг 2026, 18:29
05 авг 2026, 18:29
e54cb4b
Код
Авторство
О чём код?
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{ScrollSelectionIntoView: true}) // 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{ScrollSelectionIntoView: true}) } 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) }