Reorder commits (or rebase todos) by dragging with the mouse (#5857)

Pressing the left button on the current selection now starts a drag that
moves the selected commits, both in the normal commits view and for
todos during an interactive rebase. A press on an unselected row still
creates a range selection.

While dragging, a "drop here" indicator shows where the commits will be
inserted.

Moving commits with the mouse is useful for the case that you want to
move them a longer distance, because that's slow when doing it one by
one with the keyboard, and also you don't want to resolve conflicts at
every step. The standard workaround for that is to enter an interactive
rebase first and then continue it afterwards, but that's a bit
cumbersome; dragging solves that nicely.

Closes #5842.
This commit is contained in:
Stefan Haller
2026-07-31 08:41:18 +02:00
committed by GitHub
15 changed files with 960 additions and 81 deletions
+12 -8
View File
@@ -263,12 +263,14 @@ func (self *MoveFixupCommitDownInstruction) run(common *common.Common) error {
}
type MoveTodosUpInstruction struct {
Hashes []string
Hashes []string
Distance int
}
func NewMoveTodosUpInstruction(hashes []string) Instruction {
func NewMoveTodosUpInstruction(hashes []string, distance int) Instruction {
return &MoveTodosUpInstruction{
Hashes: hashes,
Hashes: hashes,
Distance: distance,
}
}
@@ -288,17 +290,19 @@ func (self *MoveTodosUpInstruction) run(common *common.Common) error {
})
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodosUp(path, todosToMove, false, getCommentChar())
return utils.MoveTodos(path, todosToMove, false, -self.Distance, getCommentChar())
})
}
type MoveTodosDownInstruction struct {
Hashes []string
Hashes []string
Distance int
}
func NewMoveTodosDownInstruction(hashes []string) Instruction {
func NewMoveTodosDownInstruction(hashes []string, distance int) Instruction {
return &MoveTodosDownInstruction{
Hashes: hashes,
Hashes: hashes,
Distance: distance,
}
}
@@ -318,7 +322,7 @@ func (self *MoveTodosDownInstruction) run(common *common.Common) error {
})
return handleInteractiveRebase(common, func(path string) error {
return utils.MoveTodosDown(path, todosToMove, false, getCommentChar())
return utils.MoveTodos(path, todosToMove, false, self.Distance, getCommentChar())
})
}
+20 -20
View File
@@ -112,29 +112,30 @@ func (self *RebaseCommands) GenericAmend(commits []*models.Commit, start, end in
}
func (self *RebaseCommands) MoveCommitsDown(commits []*models.Commit, startIdx int, endIdx int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+2)
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
return commit.Hash()
})
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: baseHashOrRoot,
instruction: daemon.NewMoveTodosDownInstruction(hashes),
overrideEditor: true,
}).Run()
return self.MoveCommits(commits, startIdx, endIdx, 1)
}
func (self *RebaseCommands) MoveCommitsUp(commits []*models.Commit, startIdx int, endIdx int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+1)
return self.MoveCommits(commits, startIdx, endIdx, -1)
}
func (self *RebaseCommands) MoveCommits(commits []*models.Commit, startIdx int, endIdx int, offset int) error {
baseHashOrRoot := getBaseHashOrRoot(commits, endIdx+max(offset, 0)+1)
hashes := lo.Map(commits[startIdx:endIdx+1], func(commit *models.Commit, _ int) string {
return commit.Hash()
})
var instruction daemon.Instruction
if offset > 0 {
instruction = daemon.NewMoveTodosDownInstruction(hashes, offset)
} else {
instruction = daemon.NewMoveTodosUpInstruction(hashes, -offset)
}
return self.PrepareInteractiveRebaseCommand(PrepareInteractiveRebaseCommandOpts{
baseHashOrRoot: baseHashOrRoot,
instruction: daemon.NewMoveTodosUpInstruction(hashes),
instruction: instruction,
overrideEditor: true,
}).Run()
}
@@ -369,21 +370,20 @@ func (self *RebaseCommands) DeleteUpdateRefTodos(commits []*models.Commit) error
}
func (self *RebaseCommands) MoveTodosDown(commits []*models.Commit) error {
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
return todoFromCommit(commit)
})
return utils.MoveTodosDown(fileName, todosToMove, true, self.config.GetCoreCommentChar())
return self.MoveTodos(commits, 1)
}
func (self *RebaseCommands) MoveTodosUp(commits []*models.Commit) error {
return self.MoveTodos(commits, -1)
}
func (self *RebaseCommands) MoveTodos(commits []*models.Commit, offset int) error {
fileName := filepath.Join(self.repoPaths.WorktreeGitDirPath(), "rebase-merge/git-rebase-todo")
todosToMove := lo.Map(commits, func(commit *models.Commit, _ int) utils.Todo {
return todoFromCommit(commit)
})
return utils.MoveTodosUp(fileName, todosToMove, true, self.config.GetCoreCommentChar())
return utils.MoveTodos(fileName, todosToMove, true, offset, self.config.GetCoreCommentChar())
}
// SquashAllAboveFixupCommits squashes all fixup! commits above the given one
+77
View File
@@ -1,14 +1,18 @@
package context
import (
"fmt"
"log"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/presentation"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/jesseduffield/lazygit/pkg/gui/types"
"github.com/samber/lo"
)
@@ -17,6 +21,13 @@ type LocalCommitsContext struct {
*LocalCommitsViewModel
*ListContextTrait
*SearchTrait
dropIndicator *commitDropIndicator
}
type commitDropIndicator struct {
insertionIndex int
moving bool
}
var (
@@ -26,6 +37,7 @@ var (
)
func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
dropIndicator := &commitDropIndicator{insertionIndex: -1}
viewModel := NewLocalCommitsViewModel(
func() []*models.Commit { return c.Model().Commits },
c,
@@ -94,6 +106,15 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
})
}
result = addCommitDropIndicator(
result,
dropIndicator,
c.Tr.MoveCommitsHere,
c.Tr.MovingCommitsHere,
c.UserConfig().Gui.Spinner,
time.Now(),
)
_, firstRealCommit, found := lo.FindIndexOf(
c.Model().Commits, func(c *models.Commit) bool {
return !c.IsTODO()
@@ -105,6 +126,15 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
Index: firstRealCommit,
Content: formatListSectionHeader(c.Tr.CommitsSectionHeader),
})
} else {
result = addCommitDropIndicator(
result,
dropIndicator,
c.Tr.MoveCommitsHere,
c.Tr.MovingCommitsHere,
c.UserConfig().Gui.Spinner,
time.Now(),
)
}
return result
@@ -113,6 +143,7 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
ctx := &LocalCommitsContext{
LocalCommitsViewModel: viewModel,
SearchTrait: NewSearchTrait(c),
dropIndicator: dropIndicator,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Commits,
@@ -137,6 +168,52 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
return ctx
}
func addCommitDropIndicator(
items []*NonModelItem,
indicator *commitDropIndicator,
dropLabel string,
movingLabel string,
spinnerConfig config.SpinnerConfig,
now time.Time,
) []*NonModelItem {
if indicator.insertionIndex < 0 {
return items
}
label := dropLabel
if indicator.moving {
label = fmt.Sprintf("%s %s", movingLabel, presentation.Loader(now, spinnerConfig))
}
insertAt := len(items)
for i, item := range items {
if item.Index > indicator.insertionIndex {
insertAt = i
break
}
}
return slices.Insert(items, insertAt, &NonModelItem{
Index: indicator.insertionIndex,
Content: style.FgCyan.SetBold().Sprintf("━━━━━━ %s ━━━━━━", label),
Column: 6, // align with the commit subject
})
}
func (self *LocalCommitsContext) SetDropInsertionIndex(index int) {
self.dropIndicator.insertionIndex = index
self.dropIndicator.moving = false
}
func (self *LocalCommitsContext) SetMovingCommitsInsertionIndex(index int) {
self.dropIndicator.insertionIndex = index
self.dropIndicator.moving = true
}
func (self *LocalCommitsContext) ClearDropInsertionIndex() {
self.dropIndicator.insertionIndex = -1
self.dropIndicator.moving = false
}
type LocalCommitsViewModel struct {
*ListViewModel[*models.Commit]
@@ -0,0 +1,53 @@
package context
import (
"testing"
"time"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/stretchr/testify/assert"
)
func TestAddCommitDropIndicator(t *testing.T) {
pendingHeader := &NonModelItem{Index: 0, Content: "pending"}
commitsHeader := &NonModelItem{Index: 3, Content: "commits"}
indicator := &commitDropIndicator{insertionIndex: 3}
spinnerConfig := config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100}
items := addCommitDropIndicator(
[]*NonModelItem{pendingHeader}, indicator, "drop here", "moving commits here", spinnerConfig, time.UnixMilli(0),
)
items = append(items, commitsHeader)
assert.Equal(t, []*NonModelItem{
pendingHeader,
{
Index: 3,
Content: style.FgCyan.SetBold().Sprint("━━━━━━ drop here ━━━━━━"),
Column: 6,
},
commitsHeader,
}, items)
assert.Equal(t, 6, modelIndexToViewIndex(4, items, 3))
assert.Equal(t, 3, viewIndexToModelIndex(4, items, 4))
}
func TestAddMovingCommitsIndicator(t *testing.T) {
items := addCommitDropIndicator(
nil,
&commitDropIndicator{insertionIndex: 2, moving: true},
"drop here",
"moving commits here",
config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100},
time.UnixMilli(100),
)
assert.Equal(t, []*NonModelItem{
{
Index: 2,
Content: style.FgCyan.SetBold().Sprint("━━━━━━ moving commits here two ━━━━━━"),
Column: 6,
},
}, items)
}
+425 -43
View File
@@ -2,12 +2,14 @@ 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"
@@ -19,6 +21,10 @@ import (
// 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
)
@@ -28,7 +34,48 @@ type LocalCommitsController struct {
*ListControllerTrait[*models.Commit]
c *ControllerCommon
pullFiles PullFilesFn
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{}
@@ -37,7 +84,7 @@ func NewLocalCommitsController(
c *ControllerCommon,
pullFiles PullFilesFn,
) *LocalCommitsController {
return &LocalCommitsController{
controller := &LocalCommitsController{
baseController: baseController{},
c: c,
pullFiles: pullFiles,
@@ -48,12 +95,366 @@ func NewLocalCommitsController(
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)),
@@ -734,11 +1135,21 @@ func (self *LocalCommitsController) isCherryPickingOrReverting() bool {
}
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.MoveTodosDown(selectedCommits); err != nil {
if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil {
return err
}
self.context().MoveSelection(1)
self.context().MoveSelection(offset)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
// Block input until the refresh has landed: a quick second press must
@@ -747,14 +1158,19 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
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(self.c.Tr.MovingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitDown)
err := self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx)
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,
@@ -763,45 +1179,11 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s
// lands in the same frame as the refreshed commit list.
Then: func() error {
if err == nil {
self.context().MoveSelection(1)
self.context().MoveSelection(offset)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
return nil
},
})
})
}
func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
if self.isRebasing() {
if err := self.c.Git().Rebase.MoveTodosUp(selectedCommits); err != nil {
return err
}
self.context().MoveSelection(-1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
// Block input for the same reason as in moveDown.
self.c.RefreshBlockingInput(types.RefreshOptions{
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
})
return nil
}
commits := self.c.Model().Commits
return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error {
self.c.LogAction(self.c.Tr.Actions.MoveCommitUp)
err := self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx)
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(-1)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
if onComplete != nil {
return onComplete()
}
return nil
},
@@ -4,9 +4,47 @@ import (
"testing"
"github.com/jesseduffield/lazygit/pkg/commands/models"
"github.com/jesseduffield/lazygit/pkg/utils"
"github.com/stretchr/testify/assert"
)
func TestFindCommitDragBlock(t *testing.T) {
commit := func(hash string) *models.Commit {
return models.NewCommit(&utils.StringPool{}, models.NewCommitOpts{Hash: hash})
}
identities := []commitDragIdentity{
commitDragIdentityForCommit(commit("b")),
commitDragIdentityForCommit(commit("c")),
}
t.Run("finds the original block after selection changes", func(t *testing.T) {
commits := []*models.Commit{commit("a"), commit("b"), commit("c"), commit("d")}
actual, startIndex, endIndex, found := findCommitDragBlock(commits, identities)
assert.True(t, found)
assert.Equal(t, commits[1:3], actual)
assert.Equal(t, 1, startIndex)
assert.Equal(t, 2, endIndex)
})
t.Run("rejects a block that is no longer contiguous", func(t *testing.T) {
_, _, _, found := findCommitDragBlock(
[]*models.Commit{commit("a"), commit("b"), commit("d"), commit("c")}, identities,
)
assert.False(t, found)
})
t.Run("rejects an ambiguous block", func(t *testing.T) {
_, _, _, found := findCommitDragBlock(
[]*models.Commit{commit("b"), commit("c"), commit("b"), commit("c")}, identities,
)
assert.False(t, found)
})
}
func Test_countSquashableCommitsAbove(t *testing.T) {
scenarios := []struct {
name string
+13 -1
View File
@@ -73,16 +73,28 @@ func (self *GuiDriver) MouseRelease(x, y int) {
self.replayMouseEvent(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) {
self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone)
}
func (self *GuiDriver) WaitUntilIdle() {
self.waitTillIdle()
}
func (self *GuiDriver) OnUIThreadAndWait(f func()) {
_ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil })
}
func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) {
self.replayMouseEventWithoutWaiting(x, y, buttons)
self.waitTillIdle()
}
func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) {
self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper(
tcell.NewEventMouse(x, y, buttons, 0),
0,
))
self.waitTillIdle()
}
// FocusIn simulates the terminal window regaining focus, which is how lazygit
+4
View File
@@ -373,6 +373,8 @@ type TranslationSet struct {
PendingCherryPicksSectionHeader string
PendingRevertsSectionHeader string
CommitsSectionHeader string
MoveCommitsHere string
MovingCommitsHere string
YouDied string
RewordNotSupported string
ChangingThisActionIsNotAllowed string
@@ -1523,6 +1525,8 @@ func EnglishTranslationSet() *TranslationSet {
PendingCherryPicksSectionHeader: "Pending cherry-picks",
PendingRevertsSectionHeader: "Pending reverts",
CommitsSectionHeader: "Commits",
MoveCommitsHere: "drop here",
MovingCommitsHere: "moving commits here",
YouDied: "YOU DIED!",
RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported",
ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed",
@@ -0,0 +1,35 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragKeepsSelectionHighlighted = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Keep the original commit range highlighted while dragging sideways",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(5)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Press(keys.Universal.RangeSelectDown).
Press(keys.Universal.RangeSelectDown).
ClickAndHold(1, 1).
SelectedLines(
Contains("commit-05"),
Contains("commit-04"),
Contains("commit-03"),
).
MouseMove(10, 1).
SelectedLines(
Contains("commit-05"),
Contains("commit-04"),
Contains("commit-03"),
).
MouseRelease()
},
})
@@ -0,0 +1,107 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragToReorder = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Drag a selected commit range multiple rows in one operation",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(5)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
Press(keys.Universal.RangeSelectDown).
TopLines(
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-01"),
).
ClickAndHold(1, 1).
MouseMove(1, 3).
TopLines(
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("drop here"),
Contains("commit-01"),
).
PressEscape().
TopLines(
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-01"),
).
MouseMove(1, 4).
TopLines(
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-01"),
).
MouseRelease().
ClickAndHold(1, 1).
TopLines(
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-01"),
).
MouseMove(1, 3).
TopLines(
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("drop here"),
Contains("commit-01"),
).
SelectNextItem().
SelectedLines(
Contains("commit-03"),
).
MouseRelease().
TopLines(
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-01"),
).
ClickAndHold(1, 2).
MouseMove(1, 0).
TopLines(
Contains("drop here"),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-01"),
).
MouseRelease().
TopLines(
Contains("commit-05").IsSelected(),
Contains("commit-04").IsSelected(),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-01"),
).
ClickAndHold(1, 1).
MouseRelease().
SelectedLines(
Contains("commit-04"),
)
},
})
@@ -0,0 +1,72 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragToReorderInRebase = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Drag rebase todos without allowing real commits to move",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(5)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
NavigateToLine(Contains("commit-01")).
Press(keys.Universal.Edit).
Lines(
Contains("─── Pending rebase todos"),
Contains("commit-05"),
Contains("commit-04"),
Contains("commit-03"),
Contains("commit-02"),
Contains("─── Commits"),
Contains("commit-01").IsSelected(),
).
NavigateToLine(Contains("commit-05")).
ClickAndHold(1, 1).
MouseMove(1, 6).
Lines(
Contains("─── Pending rebase todos"),
Contains("commit-05").IsSelected(),
Contains("commit-04"),
Contains("commit-03"),
Contains("commit-02"),
Contains("drop here"),
Contains("─── Commits"),
Contains("commit-01"),
).
MouseRelease().
Lines(
Contains("─── Pending rebase todos"),
Contains("commit-04"),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-05").IsSelected(),
Contains("─── Commits"),
Contains("commit-01"),
).
NavigateToLine(Contains("commit-01")).
ClickAndHold(1, 6).
MouseMove(1, 4).
SelectedLines(
Contains("commit-05"),
Contains("─── Commits"),
Contains("commit-01"),
).
MouseRelease().
Lines(
Contains("─── Pending rebase todos"),
Contains("commit-04"),
Contains("commit-03"),
Contains("commit-02"),
Contains("commit-05").IsSelected(),
Contains("─── Commits").IsSelected(),
Contains("commit-01").IsSelected(),
)
},
})
@@ -0,0 +1,35 @@
package interactive_rebase
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var DragToReorderWithAutoscroll = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Keep scrolling commits while a dragged commit is held at the panel edge",
ExtraCmdArgs: []string{},
Skip: false,
SetupConfig: func(config *config.AppConfig) {},
SetupRepo: func(shell *Shell) {
shell.CreateNCommits(40)
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Commits().
Focus().
TopLines(
Contains("commit-40").IsSelected(),
).
ClickAndHold(1, 0).
MouseMoveToBottom(1).
OriginYAtLeast(3).
MouseRelease().
SelectedLines(
Contains("commit-40"),
).
SelectedLineIdxAtLeast(3).
GotoTop().
TopLines(
Contains("commit-39").IsSelected(),
)
},
})
+4
View File
@@ -290,6 +290,10 @@ var tests = []*components.IntegrationTest{
interactive_rebase.AmendNonHeadCommitDuringRebase,
interactive_rebase.DeleteUpdateRefTodo,
interactive_rebase.DontShowBranchHeadsForTodoItems,
interactive_rebase.DragKeepsSelectionHighlighted,
interactive_rebase.DragToReorder,
interactive_rebase.DragToReorderInRebase,
interactive_rebase.DragToReorderWithAutoscroll,
interactive_rebase.DropCommitInCopiedBranchWithUpdateRef,
interactive_rebase.DropMergeCommit,
interactive_rebase.DropTodoCommitWithUpdateRef,
+22 -9
View File
@@ -144,27 +144,40 @@ func deleteTodos(todos []todo.Todo, todosToDelete []Todo) ([]todo.Todo, error) {
}
func MoveTodosDown(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error {
return MoveTodos(fileName, todosToMove, isInRebase, 1, commentChar)
}
func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error {
return MoveTodos(fileName, todosToMove, isInRebase, -1, commentChar)
}
func MoveTodos(fileName string, todosToMove []Todo, isInRebase bool, offset int, commentChar byte) error {
todos, err := ReadRebaseTodoFile(fileName, commentChar)
if err != nil {
return err
}
rearrangedTodos, err := moveTodosDown(todos, todosToMove, isInRebase)
rearrangedTodos, err := moveTodos(todos, todosToMove, isInRebase, offset)
if err != nil {
return err
}
return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar)
}
func MoveTodosUp(fileName string, todosToMove []Todo, isInRebase bool, commentChar byte) error {
todos, err := ReadRebaseTodoFile(fileName, commentChar)
if err != nil {
return err
func moveTodos(todos []todo.Todo, todosToMove []Todo, isInRebase bool, offset int) ([]todo.Todo, error) {
moveOneRow := moveTodosUp
if offset > 0 {
moveOneRow = moveTodosDown
}
rearrangedTodos, err := moveTodosUp(todos, todosToMove, isInRebase)
if err != nil {
return err
for range max(offset, -offset) {
var err error
todos, err = moveOneRow(todos, slices.Clone(todosToMove), isInRebase)
if err != nil {
return nil, err
}
}
return WriteRebaseTodoFile(fileName, rearrangedTodos, commentChar)
return todos, nil
}
func moveTodoDown(todos []todo.Todo, todoToMove Todo, isInRebase bool) ([]todo.Todo, error) {
+43
View File
@@ -3,12 +3,55 @@ package utils
import (
"errors"
"fmt"
"slices"
"testing"
"github.com/stefanhaller/git-todo-parser/todo"
"github.com/stretchr/testify/assert"
)
func TestMoveTodos(t *testing.T) {
todos := []todo.Todo{
{Command: todo.Pick, Commit: "a"},
{Command: todo.Pick, Commit: "b"},
{Command: todo.Label, Label: "hidden"},
{Command: todo.Pick, Commit: "c"},
{Command: todo.Pick, Commit: "d"},
{Command: todo.Pick, Commit: "e"},
{Command: todo.Pick, Commit: "f"},
}
t.Run("moves a range up multiple rendered rows", func(t *testing.T) {
actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "d"}, {Hash: "c"}}, false, -2)
assert.NoError(t, err)
assert.Equal(t, []todo.Todo{
{Command: todo.Pick, Commit: "a"},
{Command: todo.Pick, Commit: "b"},
{Command: todo.Label, Label: "hidden"},
{Command: todo.Pick, Commit: "e"},
{Command: todo.Pick, Commit: "f"},
{Command: todo.Pick, Commit: "c"},
{Command: todo.Pick, Commit: "d"},
}, actual)
})
t.Run("moves a range down multiple rendered rows", func(t *testing.T) {
actual, err := moveTodos(slices.Clone(todos), []Todo{{Hash: "e"}, {Hash: "d"}}, false, 2)
assert.NoError(t, err)
assert.Equal(t, []todo.Todo{
{Command: todo.Pick, Commit: "a"},
{Command: todo.Pick, Commit: "d"},
{Command: todo.Pick, Commit: "e"},
{Command: todo.Pick, Commit: "b"},
{Command: todo.Label, Label: "hidden"},
{Command: todo.Pick, Commit: "c"},
{Command: todo.Pick, Commit: "f"},
}, actual)
})
}
func TestRebaseCommands_moveTodoDown(t *testing.T) {
type scenario struct {
testName string