diff --git a/pkg/app/daemon/daemon.go b/pkg/app/daemon/daemon.go index df0e4bb49..0b33bc12b 100644 --- a/pkg/app/daemon/daemon.go +++ b/pkg/app/daemon/daemon.go @@ -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()) }) } diff --git a/pkg/commands/git_commands/rebase.go b/pkg/commands/git_commands/rebase.go index 97d48a1a0..74278b18d 100644 --- a/pkg/commands/git_commands/rebase.go +++ b/pkg/commands/git_commands/rebase.go @@ -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 diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 034b2434e..7f34ea7dc 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -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] diff --git a/pkg/gui/context/local_commits_context_test.go b/pkg/gui/context/local_commits_context_test.go new file mode 100644 index 000000000..f93af3a72 --- /dev/null +++ b/pkg/gui/context/local_commits_context_test.go @@ -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) +} diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 708f8fc28..17c1dcf30 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -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 }, diff --git a/pkg/gui/controllers/local_commits_controller_test.go b/pkg/gui/controllers/local_commits_controller_test.go index c5c5e7a5d..0f0a4d137 100644 --- a/pkg/gui/controllers/local_commits_controller_test.go +++ b/pkg/gui/controllers/local_commits_controller_test.go @@ -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 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 2e83fed9b..ac423b6f0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -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", diff --git a/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go b/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go new file mode 100644 index 000000000..32ef54272 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go @@ -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() + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go new file mode 100644 index 000000000..4d2f883a0 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go @@ -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"), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go new file mode 100644 index 000000000..1d27a1635 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go @@ -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(), + ) + }, +}) diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go new file mode 100644 index 000000000..d8086e0a3 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go @@ -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(), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 0fa259234..fe8478b9c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -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, diff --git a/pkg/utils/rebase_todo.go b/pkg/utils/rebase_todo.go index fe04cbc60..3a3577f80 100644 --- a/pkg/utils/rebase_todo.go +++ b/pkg/utils/rebase_todo.go @@ -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) { diff --git a/pkg/utils/rebase_todo_test.go b/pkg/utils/rebase_todo_test.go index 9daf7db01..a9ac1bba5 100644 --- a/pkg/utils/rebase_todo_test.go +++ b/pkg/utils/rebase_todo_test.go @@ -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