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