Keep the destination visible while commits move

Moving commits runs a rebase, which can take a while. Instead of
letting the drop indicator vanish the moment the button is released,
keep it in place and turn it into a "moving commits here" spinner once
the move takes longer than a short grace period, so that quick moves
stay free of flicker. The indicator is cleared when the post-move
refresh lands.
This commit is contained in:
Stefan Haller
2026-07-31 08:37:28 +02:00
parent cefec1c5c9
commit 104fdf34a9
5 changed files with 162 additions and 14 deletions
+36 -3
View File
@@ -1,6 +1,7 @@
package context
import (
"fmt"
"log"
"slices"
"strings"
@@ -8,6 +9,7 @@ import (
"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"
@@ -25,6 +27,7 @@ type LocalCommitsContext struct {
type commitDropIndicator struct {
insertionIndex int
moving bool
}
var (
@@ -103,7 +106,14 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
})
}
result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere)
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 {
@@ -117,7 +127,14 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
Content: formatListSectionHeader(c.Tr.CommitsSectionHeader),
})
} else {
result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere)
result = addCommitDropIndicator(
result,
dropIndicator,
c.Tr.MoveCommitsHere,
c.Tr.MovingCommitsHere,
c.UserConfig().Gui.Spinner,
time.Now(),
)
}
return result
@@ -152,11 +169,20 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
}
func addCommitDropIndicator(
items []*NonModelItem, indicator *commitDropIndicator, label string,
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 {
@@ -175,10 +201,17 @@ func addCommitDropIndicator(
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 {
+25 -1
View File
@@ -2,7 +2,9 @@ package context
import (
"testing"
"time"
"github.com/jesseduffield/lazygit/pkg/config"
"github.com/jesseduffield/lazygit/pkg/gui/style"
"github.com/stretchr/testify/assert"
)
@@ -11,8 +13,11 @@ 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")
items := addCommitDropIndicator(
[]*NonModelItem{pendingHeader}, indicator, "drop here", "moving commits here", spinnerConfig, time.UnixMilli(0),
)
items = append(items, commitsHeader)
assert.Equal(t, []*NonModelItem{
@@ -27,3 +32,22 @@ func TestAddCommitDropIndicator(t *testing.T) {
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)
}
@@ -2,6 +2,7 @@ package controllers
import (
"strings"
"time"
"github.com/go-errors/errors"
"github.com/jesseduffield/lazygit/pkg/commands/git_commands"
@@ -20,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
)
@@ -29,9 +34,10 @@ type LocalCommitsController struct {
*ListControllerTrait[*models.Commit]
c *ControllerCommon
pullFiles PullFilesFn
commitDrag *commitDragState
dragAutoscroller *helpers.DragAutoscroller
pullFiles PullFilesFn
commitDrag *commitDragState
dragAutoscroller *helpers.DragAutoscroller
movingCommitsIndicatorStop chan struct{}
}
// commitDragState tracks a mouse drag that moves the selected commits. It is
@@ -253,15 +259,16 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi
state := self.commitDrag
self.dragAutoscroller.Cancel()
self.commitDrag = nil
self.context().ClearDropInsertionIndex()
if !state.hasMoved {
self.context().ClearDropInsertionIndex()
self.context().SetSelection(state.pressedIndex)
self.c.PostRefreshUpdate(self.context())
return nil
}
self.c.PostRefreshUpdate(self.context())
if state.insertionIndex < 0 {
self.context().ClearDropInsertionIndex()
self.c.PostRefreshUpdate(self.context())
return nil
}
@@ -273,6 +280,8 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi
self.context().GetItems(), state.commitIdentities,
)
if !found {
self.context().ClearDropInsertionIndex()
self.c.PostRefreshUpdate(self.context())
return nil
}
self.context().SetSelectionRangeAndMode(
@@ -280,7 +289,69 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi
startIndex+state.rangeStartOffset,
state.rangeSelectMode,
)
return self.move(selectedCommits, startIndex, endIndex, offset)
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 {
@@ -1064,14 +1135,16 @@ func (self *LocalCommitsController) isCherryPickingOrReverting() bool {
}
func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error {
return self.move(selectedCommits, startIdx, endIdx, 1)
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)
return self.move(selectedCommits, startIdx, endIdx, -1, nil)
}
func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error {
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
@@ -1085,6 +1158,7 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start
self.c.RefreshBlockingInput(types.RefreshOptions{
Scope: []types.RefreshableView{types.REBASE_COMMITS},
CommitSelection: types.KeepCommitSelectionIndex,
Then: onComplete,
})
return nil
}
@@ -1108,6 +1182,9 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start
self.context().MoveSelection(offset)
self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true})
}
if onComplete != nil {
return onComplete()
}
return nil
},
})
+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
+2
View File
@@ -374,6 +374,7 @@ type TranslationSet struct {
PendingRevertsSectionHeader string
CommitsSectionHeader string
MoveCommitsHere string
MovingCommitsHere string
YouDied string
RewordNotSupported string
ChangingThisActionIsNotAllowed string
@@ -1525,6 +1526,7 @@ func EnglishTranslationSet() *TranslationSet {
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",