mirror of
https://github.com/jesseduffield/lazygit.git
synced 2026-08-24 10:13:41 -05:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user