From 104fdf34a9bcd50a1e7260d75d4fdc1749de3a0c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 14:55:32 +0200 Subject: [PATCH] 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. --- pkg/gui/context/local_commits_context.go | 39 +++++++- pkg/gui/context/local_commits_context_test.go | 26 ++++- .../controllers/local_commits_controller.go | 95 +++++++++++++++++-- pkg/gui/gui_driver.go | 14 ++- pkg/i18n/english.go | 2 + 5 files changed, 162 insertions(+), 14 deletions(-) diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 5c01de973..7f34ea7dc 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -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 { diff --git a/pkg/gui/context/local_commits_context_test.go b/pkg/gui/context/local_commits_context_test.go index 67597ff04..f93af3a72 100644 --- a/pkg/gui/context/local_commits_context_test.go +++ b/pkg/gui/context/local_commits_context_test.go @@ -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) +} diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 184fa7791..17c1dcf30 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -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 }, }) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 23ca3bab4..25bfcf2d5 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -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 diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 826752b53..ac423b6f0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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",