From b85483ecc09cb4811e5c80c0b7f57cea80733bf0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 09:05:24 +0200 Subject: [PATCH 1/7] Unify commit movement before adding drag destinations Merge the up/down variants of the move commands into one direction-parameterized implementation. Dragging commits is about to need moves over arbitrary distances, which we don't want to build twice. --- .../controllers/local_commits_controller.go | 65 +++++++------------ 1 file changed, 24 insertions(+), 41 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 708f8fc28..ab16a2a21 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -734,11 +734,25 @@ func (self *LocalCommitsController) isCherryPickingOrReverting() bool { } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + return self.move(selectedCommits, startIdx, endIdx, 1) +} + +func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { + return self.move(selectedCommits, startIdx, endIdx, -1) +} + +func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error { if self.isRebasing() { - if err := self.c.Git().Rebase.MoveTodosDown(selectedCommits); err != nil { + var err error + if offset > 0 { + err = self.c.Git().Rebase.MoveTodosDown(selectedCommits) + } else { + err = self.c.Git().Rebase.MoveTodosUp(selectedCommits) + } + if 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 @@ -753,45 +767,14 @@ func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, s 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) - 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}) - } - 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 + var err error + if offset > 0 { + self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) + err = self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) + } else { + self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) + err = self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) } - 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, @@ -800,7 +783,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta // 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 From e756511042bf65fdac330aec2917d53ed445db9e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 09:17:18 +0200 Subject: [PATCH 2/7] Move dragged commits in one rebase Let the todo-move primitives take a distance instead of hardcoding a single row, by iterating the one-row move in memory. Dropping a commit several rows away thus rewrites the todo file once and, outside of an interactive rebase, runs a single rebase rather than one per row. --- pkg/app/daemon/daemon.go | 20 +++++---- pkg/commands/git_commands/rebase.go | 40 ++++++++--------- .../controllers/local_commits_controller.go | 12 +----- pkg/utils/rebase_todo.go | 31 +++++++++---- pkg/utils/rebase_todo_test.go | 43 +++++++++++++++++++ 5 files changed, 99 insertions(+), 47 deletions(-) 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/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index ab16a2a21..f092fb7df 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -743,13 +743,7 @@ func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, sta func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error { if self.isRebasing() { - var err error - if offset > 0 { - err = self.c.Git().Rebase.MoveTodosDown(selectedCommits) - } else { - err = self.c.Git().Rebase.MoveTodosUp(selectedCommits) - } - if err != nil { + if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil { return err } self.context().MoveSelection(offset) @@ -767,14 +761,12 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start commits := self.c.Model().Commits return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { - var err error if offset > 0 { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) - err = self.c.Git().Rebase.MoveCommitsDown(commits, startIdx, endIdx) } else { self.c.LogAction(self.c.Tr.Actions.MoveCommitUp) - err = self.c.Git().Rebase.MoveCommitsUp(commits, startIdx, endIdx) } + err := self.c.Git().Rebase.MoveCommits(commits, startIdx, endIdx, offset) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{ BatchUIUpdates: true, 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 From b055d28fb1e9e6f3f7d9253b368eb39d6c2958b0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 09:39:09 +0200 Subject: [PATCH 3/7] Show commit insertion points during a drag Render the insertion point of a commit drag as a non-model item in the commits list. It must be inserted at the right position relative to the section headers, because the list renderer assumes non-model items are ordered by their model index. Not used yet, we'll hook it up to the drag gesture in the next commit. --- pkg/gui/context/local_commits_context.go | 44 +++++++++++++++++++ pkg/gui/context/local_commits_context_test.go | 29 ++++++++++++ pkg/i18n/english.go | 2 + 3 files changed, 75 insertions(+) create mode 100644 pkg/gui/context/local_commits_context_test.go diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 034b2434e..5c01de973 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -2,6 +2,7 @@ package context import ( "log" + "slices" "strings" "sync/atomic" "time" @@ -9,6 +10,7 @@ import ( "github.com/jesseduffield/lazygit/pkg/commands/models" "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 +19,12 @@ type LocalCommitsContext struct { *LocalCommitsViewModel *ListContextTrait *SearchTrait + + dropIndicator *commitDropIndicator +} + +type commitDropIndicator struct { + insertionIndex int } var ( @@ -26,6 +34,7 @@ var ( ) func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { + dropIndicator := &commitDropIndicator{insertionIndex: -1} viewModel := NewLocalCommitsViewModel( func() []*models.Commit { return c.Model().Commits }, c, @@ -94,6 +103,8 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { }) } + result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) + _, firstRealCommit, found := lo.FindIndexOf( c.Model().Commits, func(c *models.Commit) bool { return !c.IsTODO() @@ -105,6 +116,8 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { Index: firstRealCommit, Content: formatListSectionHeader(c.Tr.CommitsSectionHeader), }) + } else { + result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) } return result @@ -113,6 +126,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 +151,36 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { return ctx } +func addCommitDropIndicator( + items []*NonModelItem, indicator *commitDropIndicator, label string, +) []*NonModelItem { + if indicator.insertionIndex < 0 { + return items + } + + 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 +} + +func (self *LocalCommitsContext) ClearDropInsertionIndex() { + self.dropIndicator.insertionIndex = -1 +} + 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..67597ff04 --- /dev/null +++ b/pkg/gui/context/local_commits_context_test.go @@ -0,0 +1,29 @@ +package context + +import ( + "testing" + + "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} + + items := addCommitDropIndicator([]*NonModelItem{pendingHeader}, indicator, "drop here") + 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)) +} diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 2e83fed9b..826752b53 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -373,6 +373,7 @@ type TranslationSet struct { PendingCherryPicksSectionHeader string PendingRevertsSectionHeader string CommitsSectionHeader string + MoveCommitsHere string YouDied string RewordNotSupported string ChangingThisActionIsNotAllowed string @@ -1523,6 +1524,7 @@ func EnglishTranslationSet() *TranslationSet { PendingCherryPicksSectionHeader: "Pending cherry-picks", PendingRevertsSectionHeader: "Pending reverts", CommitsSectionHeader: "Commits", + MoveCommitsHere: "drop 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", From eda4ad11922429a5d6d435aaafc6389d97e9d9ab Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:22:02 +0200 Subject: [PATCH 4/7] Drag selected commits to a new position 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 anywhere else falls through to the usual click handling, so dragging from an unselected line still creates a range selection, and releasing without having moved collapses the selection to the pressed commit like a plain click would. While dragging, the insertion point follows the pointer: rows below the dragged block insert after the pointed-at commit, rows above it insert before it, and during a rebase the destination is limited to the contiguous block of movable todos around the selection. gocui moves the view cursor along with the pointer, so each drag event moves it back to keep the original selection highlighted. The move happens on release. The model may have been refreshed during the drag, so the dragged commits are located again by their identity (hash, subject, todo action); if they no longer form a unique contiguous block, the drop is ignored rather than guessing. --- .../controllers/local_commits_controller.go | 271 +++++++++++++++++- .../local_commits_controller_test.go | 38 +++ .../drag_keeps_selection_highlighted.go | 35 +++ .../interactive_rebase/drag_to_reorder.go | 80 ++++++ .../drag_to_reorder_in_rebase.go | 72 +++++ pkg/integration/tests/test_list.go | 3 + 6 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 pkg/integration/tests/interactive_rebase/drag_keeps_selection_highlighted.go create mode 100644 pkg/integration/tests/interactive_rebase/drag_to_reorder.go create mode 100644 pkg/integration/tests/interactive_rebase/drag_to_reorder_in_rebase.go diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index f092fb7df..e314c0f3f 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -8,6 +8,7 @@ import ( "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" @@ -28,7 +29,46 @@ type LocalCommitsController struct { *ListControllerTrait[*models.Commit] c *ControllerCommon - pullFiles PullFilesFn + pullFiles PullFilesFn + commitDrag *commitDragState +} + +// 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{} @@ -50,6 +90,235 @@ func NewLocalCommitsController( } } +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 + self.restoreCommitDragHighlight() + insertionIndex := self.commitDragInsertionIndex(opts.Y) + if insertionIndex >= self.commitDrag.startIndex && insertionIndex <= self.commitDrag.endIndex+1 { + insertionIndex = -1 + } + if insertionIndex == self.commitDrag.insertionIndex { + return nil + } + + self.commitDrag.insertionIndex = insertionIndex + if insertionIndex < 0 { + self.context().ClearDropInsertionIndex() + } else { + self.context().SetDropInsertionIndex(insertionIndex) + } + self.c.PostRefreshUpdate(self.context()) + return nil +} + +// 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.commitDrag = nil + self.context().ClearDropInsertionIndex() + + if !state.hasMoved { + self.context().SetSelection(state.pressedIndex) + self.c.PostRefreshUpdate(self.context()) + return nil + } + self.c.PostRefreshUpdate(self.context()) + if state.insertionIndex < 0 { + 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 { + return nil + } + self.context().SetSelectionRangeAndMode( + startIndex+state.selectedOffset, + startIndex+state.rangeStartOffset, + state.rangeSelectMode, + ) + return self.move(selectedCommits, startIndex, endIndex, offset) +} + +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.commitDrag = nil + self.c.GocuiGui().CancelMouseCapture() + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) + } +} + func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding { editCommitKey := opts.Config.Universal.Edit 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/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..77aab7d67 --- /dev/null +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go @@ -0,0 +1,80 @@ +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). + 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/test_list.go b/pkg/integration/tests/test_list.go index 0fa259234..fdc375dca 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -290,6 +290,9 @@ var tests = []*components.IntegrationTest{ interactive_rebase.AmendNonHeadCommitDuringRebase, interactive_rebase.DeleteUpdateRefTodo, interactive_rebase.DontShowBranchHeadsForTodoItems, + interactive_rebase.DragKeepsSelectionHighlighted, + interactive_rebase.DragToReorder, + interactive_rebase.DragToReorderInRebase, interactive_rebase.DropCommitInCopiedBranchWithUpdateRef, interactive_rebase.DropMergeCommit, interactive_rebase.DropTodoCommitWithUpdateRef, From 0738d55551439d79ef9de424e37a3a08f957164b Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 24 Jul 2026 08:24:04 +0200 Subject: [PATCH 5/7] Keep scrolling while commits are dragged at an edge Reuse the drag autoscroller for commit drags. Scrolling stops once the insertion point reaches the end of the allowed range in the scroll direction, so during a rebase the view doesn't keep scrolling once the last insertion position among the todos has been reached. --- .../controllers/local_commits_controller.go | 58 ++++++++++++++++--- .../drag_to_reorder_with_autoscroll.go | 35 +++++++++++ pkg/integration/tests/test_list.go | 1 + 3 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 pkg/integration/tests/interactive_rebase/drag_to_reorder_with_autoscroll.go diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index e314c0f3f..8522c39bf 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -29,8 +29,9 @@ type LocalCommitsController struct { *ListControllerTrait[*models.Commit] c *ControllerCommon - pullFiles PullFilesFn - commitDrag *commitDragState + pullFiles PullFilesFn + commitDrag *commitDragState + dragAutoscroller *helpers.DragAutoscroller } // commitDragState tracks a mouse drag that moves the selected commits. It is @@ -77,7 +78,7 @@ func NewLocalCommitsController( c *ControllerCommon, pullFiles PullFilesFn, ) *LocalCommitsController { - return &LocalCommitsController{ + controller := &LocalCommitsController{ baseController: baseController{}, c: c, pullFiles: pullFiles, @@ -88,6 +89,13 @@ 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 { @@ -176,13 +184,22 @@ func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBinding } 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() - insertionIndex := self.commitDragInsertionIndex(opts.Y) + 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 nil + return false } self.commitDrag.insertionIndex = insertionIndex @@ -191,8 +208,7 @@ func (self *LocalCommitsController) handleCommitDrag(opts gocui.ViewMouseBinding } else { self.context().SetDropInsertionIndex(insertionIndex) } - self.c.PostRefreshUpdate(self.context()) - return nil + return true } // gocui moves the view cursor to the pointer position before invoking our @@ -235,6 +251,7 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi } state := self.commitDrag + self.dragAutoscroller.Cancel() self.commitDrag = nil self.context().ClearDropInsertionIndex() @@ -312,6 +329,7 @@ func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) return } + self.dragAutoscroller.Cancel() self.commitDrag = nil self.c.GocuiGui().CancelMouseCapture() self.context().ClearDropInsertionIndex() @@ -319,6 +337,32 @@ func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) } } +// 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 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 fdc375dca..fe8478b9c 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -293,6 +293,7 @@ var tests = []*components.IntegrationTest{ interactive_rebase.DragKeepsSelectionHighlighted, interactive_rebase.DragToReorder, interactive_rebase.DragToReorderInRebase, + interactive_rebase.DragToReorderWithAutoscroll, interactive_rebase.DropCommitInCopiedBranchWithUpdateRef, interactive_rebase.DropMergeCommit, interactive_rebase.DropTodoCommitWithUpdateRef, From cefec1c5c9f8da7692b390d34b0405fbcdb8d375 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 12:47:28 +0200 Subject: [PATCH 6/7] Cancel commit drags with escape While a commit drag is in progress, escape now aborts it: the drag state and the drop indicator are discarded and the mouse capture is released, so nothing happens when the button is eventually released. Otherwise escape keeps its normal meaning. --- .../controllers/local_commits_controller.go | 27 +++++++++++++++---- .../interactive_rebase/drag_to_reorder.go | 27 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 8522c39bf..184fa7791 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -329,14 +329,27 @@ func (self *LocalCommitsController) GetOnFocusLost() func(types.OnFocusLostOpts) return } - self.dragAutoscroller.Cancel() - self.commitDrag = nil - self.c.GocuiGui().CancelMouseCapture() - self.context().ClearDropInsertionIndex() - self.c.PostRefreshUpdate(self.context()) + 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. @@ -367,6 +380,10 @@ func (self *LocalCommitsController) GetKeybindings(opts types.KeybindingsOpts) [ 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)), diff --git a/pkg/integration/tests/interactive_rebase/drag_to_reorder.go b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go index 77aab7d67..4d2f883a0 100644 --- a/pkg/integration/tests/interactive_rebase/drag_to_reorder.go +++ b/pkg/integration/tests/interactive_rebase/drag_to_reorder.go @@ -25,6 +25,33 @@ var DragToReorder = NewIntegrationTest(NewIntegrationTestArgs{ 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(), From 104fdf34a9bcd50a1e7260d75d4fdc1749de3a0c Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 23 Jul 2026 14:55:32 +0200 Subject: [PATCH 7/7] Keep the destination visible while commits move Moving commits runs a rebase, which can take a while. Instead of letting the drop indicator vanish the moment the button is released, keep it in place and turn it into a "moving commits here" spinner once the move takes longer than a short grace period, so that quick moves stay free of flicker. The indicator is cleared when the post-move refresh lands. --- pkg/gui/context/local_commits_context.go | 39 +++++++- pkg/gui/context/local_commits_context_test.go | 26 ++++- .../controllers/local_commits_controller.go | 95 +++++++++++++++++-- pkg/gui/gui_driver.go | 14 ++- pkg/i18n/english.go | 2 + 5 files changed, 162 insertions(+), 14 deletions(-) diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 5c01de973..7f34ea7dc 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -1,6 +1,7 @@ package context import ( + "fmt" "log" "slices" "strings" @@ -8,6 +9,7 @@ import ( "time" "github.com/jesseduffield/lazygit/pkg/commands/models" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/presentation" "github.com/jesseduffield/lazygit/pkg/gui/style" @@ -25,6 +27,7 @@ type LocalCommitsContext struct { type commitDropIndicator struct { insertionIndex int + moving bool } var ( @@ -103,7 +106,14 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { }) } - result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) + result = addCommitDropIndicator( + result, + dropIndicator, + c.Tr.MoveCommitsHere, + c.Tr.MovingCommitsHere, + c.UserConfig().Gui.Spinner, + time.Now(), + ) _, firstRealCommit, found := lo.FindIndexOf( c.Model().Commits, func(c *models.Commit) bool { @@ -117,7 +127,14 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { Content: formatListSectionHeader(c.Tr.CommitsSectionHeader), }) } else { - result = addCommitDropIndicator(result, dropIndicator, c.Tr.MoveCommitsHere) + result = addCommitDropIndicator( + result, + dropIndicator, + c.Tr.MoveCommitsHere, + c.Tr.MovingCommitsHere, + c.UserConfig().Gui.Spinner, + time.Now(), + ) } return result @@ -152,11 +169,20 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { } func addCommitDropIndicator( - items []*NonModelItem, indicator *commitDropIndicator, label string, + items []*NonModelItem, + indicator *commitDropIndicator, + dropLabel string, + movingLabel string, + spinnerConfig config.SpinnerConfig, + now time.Time, ) []*NonModelItem { if indicator.insertionIndex < 0 { return items } + label := dropLabel + if indicator.moving { + label = fmt.Sprintf("%s %s", movingLabel, presentation.Loader(now, spinnerConfig)) + } insertAt := len(items) for i, item := range items { @@ -175,10 +201,17 @@ func addCommitDropIndicator( func (self *LocalCommitsContext) SetDropInsertionIndex(index int) { self.dropIndicator.insertionIndex = index + self.dropIndicator.moving = false +} + +func (self *LocalCommitsContext) SetMovingCommitsInsertionIndex(index int) { + self.dropIndicator.insertionIndex = index + self.dropIndicator.moving = true } func (self *LocalCommitsContext) ClearDropInsertionIndex() { self.dropIndicator.insertionIndex = -1 + self.dropIndicator.moving = false } type LocalCommitsViewModel struct { diff --git a/pkg/gui/context/local_commits_context_test.go b/pkg/gui/context/local_commits_context_test.go index 67597ff04..f93af3a72 100644 --- a/pkg/gui/context/local_commits_context_test.go +++ b/pkg/gui/context/local_commits_context_test.go @@ -2,7 +2,9 @@ package context import ( "testing" + "time" + "github.com/jesseduffield/lazygit/pkg/config" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/stretchr/testify/assert" ) @@ -11,8 +13,11 @@ func TestAddCommitDropIndicator(t *testing.T) { pendingHeader := &NonModelItem{Index: 0, Content: "pending"} commitsHeader := &NonModelItem{Index: 3, Content: "commits"} indicator := &commitDropIndicator{insertionIndex: 3} + spinnerConfig := config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100} - items := addCommitDropIndicator([]*NonModelItem{pendingHeader}, indicator, "drop here") + items := addCommitDropIndicator( + []*NonModelItem{pendingHeader}, indicator, "drop here", "moving commits here", spinnerConfig, time.UnixMilli(0), + ) items = append(items, commitsHeader) assert.Equal(t, []*NonModelItem{ @@ -27,3 +32,22 @@ func TestAddCommitDropIndicator(t *testing.T) { assert.Equal(t, 6, modelIndexToViewIndex(4, items, 3)) assert.Equal(t, 3, viewIndexToModelIndex(4, items, 4)) } + +func TestAddMovingCommitsIndicator(t *testing.T) { + items := addCommitDropIndicator( + nil, + &commitDropIndicator{insertionIndex: 2, moving: true}, + "drop here", + "moving commits here", + config.SpinnerConfig{Frames: []string{"one", "two"}, Rate: 100}, + time.UnixMilli(100), + ) + + assert.Equal(t, []*NonModelItem{ + { + Index: 2, + Content: style.FgCyan.SetBold().Sprint("━━━━━━ moving commits here two ━━━━━━"), + Column: 6, + }, + }, items) +} diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 184fa7791..17c1dcf30 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -2,6 +2,7 @@ package controllers import ( "strings" + "time" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/commands/git_commands" @@ -20,6 +21,10 @@ import ( // after selecting the 200th commit, we'll load in all the rest const COMMIT_THRESHOLD = 200 +// How long a commit move may take before the drop indicator switches to a +// "moving commits here" spinner; quick moves stay free of flicker. +const commitDragMovingIndicatorDelay = 200 * time.Millisecond + type ( PullFilesFn func() error ) @@ -29,9 +34,10 @@ type LocalCommitsController struct { *ListControllerTrait[*models.Commit] c *ControllerCommon - pullFiles PullFilesFn - commitDrag *commitDragState - dragAutoscroller *helpers.DragAutoscroller + pullFiles PullFilesFn + commitDrag *commitDragState + dragAutoscroller *helpers.DragAutoscroller + movingCommitsIndicatorStop chan struct{} } // commitDragState tracks a mouse drag that moves the selected commits. It is @@ -253,15 +259,16 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi state := self.commitDrag self.dragAutoscroller.Cancel() self.commitDrag = nil - self.context().ClearDropInsertionIndex() if !state.hasMoved { + self.context().ClearDropInsertionIndex() self.context().SetSelection(state.pressedIndex) self.c.PostRefreshUpdate(self.context()) return nil } - self.c.PostRefreshUpdate(self.context()) if state.insertionIndex < 0 { + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) return nil } @@ -273,6 +280,8 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi self.context().GetItems(), state.commitIdentities, ) if !found { + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) return nil } self.context().SetSelectionRangeAndMode( @@ -280,7 +289,69 @@ func (self *LocalCommitsController) handleCommitDragRelease(gocui.ViewMouseBindi startIndex+state.rangeStartOffset, state.rangeSelectMode, ) - return self.move(selectedCommits, startIndex, endIndex, offset) + self.startMovingCommitsIndicator(state.insertionIndex) + if err := self.move(selectedCommits, startIndex, endIndex, offset, + func() error { self.stopMovingCommitsIndicator(); return nil }); err != nil { + self.stopMovingCommitsIndicator() + return err + } + return nil +} + +// startMovingCommitsIndicator keeps the drop indicator visible while the move +// is running, turning it into a spinner once the grace period elapses. The +// ticker goroutine only ever touches state from the UI thread, where the +// comparison against the current stop channel makes late callbacks harmless. +func (self *LocalCommitsController) startMovingCommitsIndicator(insertionIndex int) { + self.stopMovingCommitsIndicatorTicker() + stop := make(chan struct{}) + self.movingCommitsIndicatorStop = stop + go utils.Safe(func() { + graceTimer := time.NewTimer(commitDragMovingIndicatorDelay) + defer graceTimer.Stop() + select { + case <-graceTimer.C: + self.c.OnUIThreadContentOnlyBackground(func() error { + if self.movingCommitsIndicatorStop == stop { + self.context().SetMovingCommitsInsertionIndex(insertionIndex) + self.context().HandleRender() + } + return nil + }) + case <-stop: + return + } + + rate := time.Millisecond * time.Duration(self.c.UserConfig().Gui.Spinner.Rate) + ticker := time.NewTicker(rate) + defer ticker.Stop() + for { + select { + case <-ticker.C: + self.c.OnUIThreadContentOnlyBackground(func() error { + if self.movingCommitsIndicatorStop == stop { + self.context().HandleRender() + } + return nil + }) + case <-stop: + return + } + } + }) +} + +func (self *LocalCommitsController) stopMovingCommitsIndicator() { + self.stopMovingCommitsIndicatorTicker() + self.context().ClearDropInsertionIndex() + self.c.PostRefreshUpdate(self.context()) +} + +func (self *LocalCommitsController) stopMovingCommitsIndicatorTicker() { + if self.movingCommitsIndicatorStop != nil { + close(self.movingCommitsIndicatorStop) + self.movingCommitsIndicatorStop = nil + } } func commitDragIdentityForCommit(commit *models.Commit) commitDragIdentity { @@ -1064,14 +1135,16 @@ func (self *LocalCommitsController) isCherryPickingOrReverting() bool { } func (self *LocalCommitsController) moveDown(selectedCommits []*models.Commit, startIdx int, endIdx int) error { - return self.move(selectedCommits, startIdx, endIdx, 1) + return self.move(selectedCommits, startIdx, endIdx, 1, nil) } func (self *LocalCommitsController) moveUp(selectedCommits []*models.Commit, startIdx int, endIdx int) error { - return self.move(selectedCommits, startIdx, endIdx, -1) + return self.move(selectedCommits, startIdx, endIdx, -1, nil) } -func (self *LocalCommitsController) move(selectedCommits []*models.Commit, startIdx int, endIdx int, offset int) error { +func (self *LocalCommitsController) move( + selectedCommits []*models.Commit, startIdx int, endIdx int, offset int, onComplete func() error, +) error { if self.isRebasing() { if err := self.c.Git().Rebase.MoveTodos(selectedCommits, offset); err != nil { return err @@ -1085,6 +1158,7 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start self.c.RefreshBlockingInput(types.RefreshOptions{ Scope: []types.RefreshableView{types.REBASE_COMMITS}, CommitSelection: types.KeepCommitSelectionIndex, + Then: onComplete, }) return nil } @@ -1108,6 +1182,9 @@ func (self *LocalCommitsController) move(selectedCommits []*models.Commit, start self.context().MoveSelection(offset) self.context().HandleFocus(types.OnFocusOpts{ScrollSelectionIntoView: true}) } + if onComplete != nil { + return onComplete() + } return nil }, }) diff --git a/pkg/gui/gui_driver.go b/pkg/gui/gui_driver.go index 23ca3bab4..25bfcf2d5 100644 --- a/pkg/gui/gui_driver.go +++ b/pkg/gui/gui_driver.go @@ -73,16 +73,28 @@ func (self *GuiDriver) MouseRelease(x, y int) { self.replayMouseEvent(x, y, tcell.ButtonNone) } +func (self *GuiDriver) MouseReleaseWithoutWaiting(x, y int) { + self.replayMouseEventWithoutWaiting(x, y, tcell.ButtonNone) +} + +func (self *GuiDriver) WaitUntilIdle() { + self.waitTillIdle() +} + func (self *GuiDriver) OnUIThreadAndWait(f func()) { _ = self.gui.g.OnUIThreadAndWait(func() error { f(); return nil }) } func (self *GuiDriver) replayMouseEvent(x, y int, buttons tcell.ButtonMask) { + self.replayMouseEventWithoutWaiting(x, y, buttons) + self.waitTillIdle() +} + +func (self *GuiDriver) replayMouseEventWithoutWaiting(x, y int, buttons tcell.ButtonMask) { self.gui.g.ReplayMouseEvent(gocui.NewTcellMouseEventWrapper( tcell.NewEventMouse(x, y, buttons, 0), 0, )) - self.waitTillIdle() } // FocusIn simulates the terminal window regaining focus, which is how lazygit diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 826752b53..ac423b6f0 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -374,6 +374,7 @@ type TranslationSet struct { PendingRevertsSectionHeader string CommitsSectionHeader string MoveCommitsHere string + MovingCommitsHere string YouDied string RewordNotSupported string ChangingThisActionIsNotAllowed string @@ -1525,6 +1526,7 @@ func EnglishTranslationSet() *TranslationSet { PendingRevertsSectionHeader: "Pending reverts", CommitsSectionHeader: "Commits", MoveCommitsHere: "drop here", + MovingCommitsHere: "moving commits here", YouDied: "YOU DIED!", RewordNotSupported: "Rewording commits while interactively rebasing is not currently supported", ChangingThisActionIsNotAllowed: "Changing this kind of rebase todo entry is not allowed",