From f8b7bab1abbd872b583553ab46d23ef009b105a9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 09:14:02 +0200 Subject: [PATCH 1/6] Decide the commit graph from the loaded list, not the filtering mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a graph can be drawn was read from the filtering mode, while the graph itself is drawn over the commit list in the model. Those two only agree once the list has been reloaded for the new mode, and a filtering mode change reloads the list in the background, so in between we can be asked to draw a graph over a list the graph makes no sense for. That is not just cosmetic. Commits in a filtered list are almost never each other's parents, so no pipe ever terminates: the pipe set grows by one per row and every continuing pipe rescans it, which is cubic in the length of the list. Escaping out of filtering mode with a filtered list of 13000 commits — as you get once the 300 commit limit has been lifted, which happens for good as soon as the selection passes COMMIT_THRESHOLD — wedges the UI thread for around twenty minutes. Record whether the list was loaded with a filter, right where the list itself is stored, and decide from that. The graph now also stays up while the pre-change list is still on display, rather than vanishing a moment before the list it belongs to. --- pkg/gui/context/local_commits_context.go | 8 +++++++- pkg/gui/controllers/helpers/refresh_helper.go | 1 + pkg/gui/types/common.go | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index 7f34ea7dc..4a99259fd 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -324,7 +324,13 @@ func (self *LocalCommitsViewModel) GetCommits() []*models.Commit { } func shouldShowGraph(c *ContextCommon) bool { - if c.Modes().Filtering.Active() { + // Whether we can draw a graph is a property of the commit list we have + // loaded, not of the filtering mode: turning filtering on or off only + // reaches the screen when the reloaded list does, and until then the graph + // has to keep matching the list that is still on display. Drawing one for a + // filtered list is also ruinously slow, because none of the commits in it + // are connected to each other, so no pipe ever terminates. + if c.Model().CommitsWereFilteredAtLastRefresh { return false } diff --git a/pkg/gui/controllers/helpers/refresh_helper.go b/pkg/gui/controllers/helpers/refresh_helper.go index 3db9ab9dc..d0327216c 100644 --- a/pkg/gui/controllers/helpers/refresh_helper.go +++ b/pkg/gui/controllers/helpers/refresh_helper.go @@ -817,6 +817,7 @@ func (self *RefreshHelper) refreshCommitsWithLimit(captured capturedCommitState, self.c.Model().BisectInfo = bisectInfo self.c.Model().Commits = commits + self.c.Model().CommitsWereFilteredAtLastRefresh = captured.filterPath != "" || captured.filterAuthor != "" self.RefreshAuthors(commits) self.c.Model().WorkingTreeStateAtLastCommitRefresh = workingTreeState if checkedOutRef != nil { diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 7091d46f7..2b5fb0ac7 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -349,6 +349,7 @@ type Model struct { BisectInfo *git_commands.BisectInfo WorkingTreeStateAtLastCommitRefresh models.WorkingTreeState + CommitsWereFilteredAtLastRefresh bool RemoteBranches []*models.RemoteBranch Tags []*models.Tag From f4968f6839409ae0c019dd026c50c8362de1cfcd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:10:57 +0200 Subject: [PATCH 2/6] Rename suppressRebasingMode to suppressWorkingTreeStateMode The mode it suppresses is active for any working tree state, not just a rebase: merging, cherry-picking and reverting show through the same indicator. Name it after what it hides. --- .../controllers/helpers/app_status_helper.go | 4 ++-- pkg/gui/controllers/helpers/mode_helper.go | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index d0bb03395..99664445c 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -97,7 +97,7 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f fu // Hide the rebasing-mode indicator (and its reset button) while we drive the // rebase ourselves; it reflects the transient on-disk state and would // otherwise flash on for the duration of the operation. - self.modeHelper.SetSuppressRebasingMode(true) + self.modeHelper.SetSuppressWorkingTreeStateMode(true) self.c.OnWorker(func(task gocui.Task) error { // End the block and restore the mode indicator once the operation and its // refresh have applied their UI updates: OnUIThread queues this after the @@ -105,7 +105,7 @@ func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f fu // enqueued by the time f returns), so the replayed keys act on the // refreshed state and any resulting rebase state shows correctly. defer self.c.OnUIThread(func() error { - self.modeHelper.SetSuppressRebasingMode(false) + self.modeHelper.SetSuppressWorkingTreeStateMode(false) return self.c.GocuiGui().EndBlockingEvents() }) return self.WithWaitingStatusImpl(message, f, task) diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 4947e42d1..50f35341b 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -12,12 +12,12 @@ import ( type ModeHelper struct { c *HelperCommon - diffHelper *DiffHelper - patchBuildingHelper *PatchBuildingHelper - cherryPickHelper *CherryPickHelper - mergeAndRebaseHelper *MergeAndRebaseHelper - bisectHelper *BisectHelper - suppressRebasingMode bool + diffHelper *DiffHelper + patchBuildingHelper *PatchBuildingHelper + cherryPickHelper *CherryPickHelper + mergeAndRebaseHelper *MergeAndRebaseHelper + bisectHelper *BisectHelper + suppressWorkingTreeStateMode bool } func NewModeHelper( @@ -130,7 +130,7 @@ func (self *ModeHelper) Statuses() []ModeStatus { }, { IsActive: func() bool { - return !self.suppressRebasingMode && self.c.Git().Status.WorkingTreeState().Any() + return !self.suppressWorkingTreeStateMode && self.c.Git().Status.WorkingTreeState().Any() }, InfoLabel: func() string { workingTreeState := self.c.Git().Status.WorkingTreeState() @@ -219,6 +219,6 @@ func ScopesToRefreshWhenFilteringModeChanges() []types.RefreshableView { } } -func (self *ModeHelper) SetSuppressRebasingMode(value bool) { - self.suppressRebasingMode = value +func (self *ModeHelper) SetSuppressWorkingTreeStateMode(value bool) { + self.suppressWorkingTreeStateMode = value } From e54cb4bf4265e73643cd4f8a77d080236b65d266 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:15:04 +0200 Subject: [PATCH 3/6] Decouple hiding the working tree state from blocking input Blocking keyboard input and hiding the working tree state mode are two separate concerns; they were fused into one helper because every caller so far wanted both. A caller that blocks input for something other than a rebase would then hide the "Rebasing" indicator for the duration of its operation, which has nothing to do with it. Make it an explicit option instead, so blocking input on its own doesn't imply anything about the modes on display. --- .../controllers/commits_files_controller.go | 5 +- .../controllers/helpers/app_status_helper.go | 24 +++--- .../controllers/helpers/cherry_pick_helper.go | 5 +- .../controllers/local_commits_controller.go | 80 +++++++++++++++---- pkg/gui/gui.go | 4 +- pkg/gui/popup/popup_handler.go | 8 +- pkg/gui/types/common.go | 16 +++- 7 files changed, 106 insertions(+), 36 deletions(-) diff --git a/pkg/gui/controllers/commits_files_controller.go b/pkg/gui/controllers/commits_files_controller.go index 6c8f49e1f..748355f67 100644 --- a/pkg/gui/controllers/commits_files_controller.go +++ b/pkg/gui/controllers/commits_files_controller.go @@ -339,7 +339,10 @@ func (self *CommitFilesController) discard(selectedNodes []*filetree.CommitFileN HandleConfirm: func() error { commits := self.c.Model().Commits selectedLineIdx := self.c.Contexts().LocalCommits.GetSelectedLineIdx() - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { var filePaths []string selectedNodes = normalisedSelectedCommitFileNodes(selectedNodes) diff --git a/pkg/gui/controllers/helpers/app_status_helper.go b/pkg/gui/controllers/helpers/app_status_helper.go index 99664445c..44de70546 100644 --- a/pkg/gui/controllers/helpers/app_status_helper.go +++ b/pkg/gui/controllers/helpers/app_status_helper.go @@ -85,30 +85,32 @@ func (self *AppStatusHelper) WithWaitingStatusImpl(message string, f func(gocui. // WithWaitingStatusBlockingInput is like WithWaitingStatus, but it also blocks // keyboard input for the whole duration of the operation: keys the user presses // while it runs are buffered and replayed against the post-operation state (see -// gocui.BeginBlockingEvents). Use it for operations that manipulate an -// in-progress rebase or otherwise rewrite commits, where a racing keypress -// would target the wrong commit or todo. +// gocui.BeginBlockingEvents). Use it for operations whose following keypress +// depends on the state they produce, e.g. ones that manipulate an in-progress +// rebase or otherwise rewrite commits, where a racing keypress would target the +// wrong commit or todo. // // Must be called on the UI thread: the block is begun synchronously here, before // the operation is dispatched to a worker, so no keypress can slip through in // between. -func (self *AppStatusHelper) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) { +func (self *AppStatusHelper) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) { self.c.GocuiGui().BeginBlockingEvents() - // Hide the rebasing-mode indicator (and its reset button) while we drive the - // rebase ourselves; it reflects the transient on-disk state and would - // otherwise flash on for the duration of the operation. - self.modeHelper.SetSuppressWorkingTreeStateMode(true) + if opts.HideWorkingTreeState { + self.modeHelper.SetSuppressWorkingTreeStateMode(true) + } self.c.OnWorker(func(task gocui.Task) error { // End the block and restore the mode indicator once the operation and its // refresh have applied their UI updates: OnUIThread queues this after the // refresh's model bounces and Then (which RefreshFromWorker has already // enqueued by the time f returns), so the replayed keys act on the - // refreshed state and any resulting rebase state shows correctly. + // refreshed state and any resulting working tree state shows correctly. defer self.c.OnUIThread(func() error { - self.modeHelper.SetSuppressWorkingTreeStateMode(false) + if opts.HideWorkingTreeState { + self.modeHelper.SetSuppressWorkingTreeStateMode(false) + } return self.c.GocuiGui().EndBlockingEvents() }) - return self.WithWaitingStatusImpl(message, f, task) + return self.WithWaitingStatusImpl(opts.Message, f, task) }) } diff --git a/pkg/gui/controllers/helpers/cherry_pick_helper.go b/pkg/gui/controllers/helpers/cherry_pick_helper.go index fc96b9d1b..f82f8843a 100644 --- a/pkg/gui/controllers/helpers/cherry_pick_helper.go +++ b/pkg/gui/controllers/helpers/cherry_pick_helper.go @@ -85,7 +85,10 @@ func (self *CherryPickHelper) Paste() error { HandleConfirm: func() error { mustStash := IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) cherryPickedCommits := self.getData().CherryPickedCommits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CherryPickingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CherryPickingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.CherryPick) if mustStash { diff --git a/pkg/gui/controllers/local_commits_controller.go b/pkg/gui/controllers/local_commits_controller.go index 17c1dcf30..642263d12 100644 --- a/pkg/gui/controllers/local_commits_controller.go +++ b/pkg/gui/controllers/local_commits_controller.go @@ -743,7 +743,10 @@ func (self *LocalCommitsController) squashDown(selectedCommits []*models.Commit, HandleConfirm: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.SquashingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashCommitDown) return self.interactiveRebase(commits, todo.Squash, startIdx, endIdx) }) @@ -767,7 +770,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.FixingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommit) return self.interactiveRebase(commits, todo.Fixup, startIdx, endIdx) }) @@ -780,7 +786,10 @@ func (self *LocalCommitsController) fixup(selectedCommits []*models.Commit, star OnPress: func() error { commits := self.c.Model().Commits self.selectRebaseResultCommit(startIdx) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.FixingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.FixingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.FixupCommitKeepMessage) return self.interactiveRebaseWithFlag(commits, todo.Fixup, startIdx, endIdx, "-C") }) @@ -891,7 +900,10 @@ func (self *LocalCommitsController) handleReword(summary string, description str self.c.Tr.RewordingStatus, nil, nil) } - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RewordingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RewordingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { err := self.c.Git().Rebase.RewordCommit(commits, selectedIdx, summary, description) if err != nil { return err @@ -977,7 +989,10 @@ func (self *LocalCommitsController) drop(selectedCommits []*models.Commit, start if !isMerge { self.selectRebaseResultCommit(startIdx) } - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.DroppingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.DroppingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.DropCommit) if isMerge { return self.dropMergeCommit(commits, startIdx) @@ -1002,7 +1017,10 @@ func (self *LocalCommitsController) edit(selectedCommits []*models.Commit, start commits := self.c.Model().Commits if !commits[endIdx].IsMerge() { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { err := self.c.Git().Rebase.InteractiveRebase(commits, startIdx, endIdx, todo.Edit, "") return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( err, types.RefreshOptions{BatchUIUpdates: true}) @@ -1024,7 +1042,10 @@ func (self *LocalCommitsController) quickStartInteractiveRebase() error { func (self *LocalCommitsController) startInteractiveRebaseWithEdit( commitsToEdit []*models.Commit, ) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RebasingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RebasingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.EditCommit) err := self.c.Git().Rebase.EditRebase(commitsToEdit[len(commitsToEdit)-1].Hash()) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( @@ -1164,7 +1185,10 @@ func (self *LocalCommitsController) move( } commits := self.c.Model().Commits - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.MovingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.MovingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if offset > 0 { self.c.LogAction(self.c.Tr.Actions.MoveCommitDown) } else { @@ -1209,7 +1233,10 @@ func (self *LocalCommitsController) amendTo(commit *models.Commit) error { selectedIdx := self.context().GetView().SelectedLineIdx() handleCommit = func() error { return self.c.Helpers().WorkingTree.WithEnsureCommittableFiles(func() error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AmendCommit) err := self.c.Git().Rebase.AmendTo(commits, selectedIdx) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebase(err) @@ -1271,7 +1298,10 @@ func (self *LocalCommitsController) amendAttribute(_ []*models.Commit, start, en } func (self *LocalCommitsController) resetAuthor(commits []*models.Commit, start, end int) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.ResetCommitAuthor) if err := self.c.Git().Rebase.ResetCommitAuthor(commits, start, end); err != nil { return err @@ -1287,7 +1317,10 @@ func (self *LocalCommitsController) setAuthor(commits []*models.Commit, start, e Title: self.c.Tr.SetAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SetCommitAuthor) if err := self.c.Git().Rebase.SetCommitAuthor(commits, start, end, value); err != nil { return err @@ -1307,7 +1340,10 @@ func (self *LocalCommitsController) addCoAuthor(commits []*models.Commit, start, Title: self.c.Tr.AddCoAuthorPromptTitle, FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), HandleConfirm: func(value string) error { - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.AmendingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.AmendingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.AddCommitCoAuthor) if err := self.c.Git().Rebase.AddCommitCoAuthor(commits, start, end, value); err != nil { return err @@ -1341,7 +1377,10 @@ func (self *LocalCommitsController) revert(commits []*models.Commit, start, end HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.RevertCommit) mustStash := helpers.IsWorkingTreeDirtyExceptSubmodules(self.c.Model().Files, self.c.Model().Submodules) - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.RevertingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.RevertingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if mustStash { if err := self.c.Git().Stash.Push(self.c.Tr.AutoStashForReverting); err != nil { return err @@ -1392,7 +1431,10 @@ func (self *LocalCommitsController) createFixupCommit(commit *models.Commit) err selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CreatingFixupCommitStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if err := self.c.Git().Commit.CreateFixupCommit(commit.Hash()); err != nil { return err } @@ -1500,7 +1542,10 @@ func (self *LocalCommitsController) createAmendCommit(commit *models.Commit, inc selectedIdx := self.context().GetSelectedLineIdx() commits := self.c.Model().Commits branches := self.c.Model().Branches - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.CreatingFixupCommitStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.CreatingFixupCommitStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { if err := self.c.Git().Commit.CreateAmendCommit(originalSubject, summary, description, includeFileChanges); err != nil { return err } @@ -1561,7 +1606,10 @@ func (self *LocalCommitsController) squashFixupsImpl(commit *models.Commit, reba // up by that many rows to stay on the same commit. Compute the target as an // absolute index now, on the current list. targetIdx := self.context().GetSelectedLineIdx() - selectionOffset - return self.c.WithWaitingStatusBlockingInput(self.c.Tr.SquashingStatus, func(gocui.Task) error { + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{ + Message: self.c.Tr.SquashingStatus, + HideWorkingTreeState: true, + }, func(gocui.Task) error { self.c.LogAction(self.c.Tr.Actions.SquashAllAboveFixupCommits) err := self.c.Git().Rebase.SquashAllAboveFixupCommits(commit) return self.c.Helpers().MergeAndRebase.CheckMergeOrRebaseWithRefreshOptions( diff --git a/pkg/gui/gui.go b/pkg/gui/gui.go index 4dcd3c209..a7466d584 100644 --- a/pkg/gui/gui.go +++ b/pkg/gui/gui.go @@ -832,8 +832,8 @@ func NewGui( return nil }, func(message string, f func(gocui.Task) error) { gui.helpers.AppStatus.WithWaitingStatus(message, f) }, - func(message string, f func(gocui.Task) error) { - gui.helpers.AppStatus.WithWaitingStatusBlockingInput(message, f) + func(opts types.WaitingStatusOpts, f func(gocui.Task) error) { + gui.helpers.AppStatus.WithWaitingStatusBlockingInput(opts, f) }, func(message string, kind types.ToastKind) { gui.helpers.AppStatus.Toast(message, kind) }, func() string { return gui.Views.Prompt.TextArea.GetContent() }, diff --git a/pkg/gui/popup/popup_handler.go b/pkg/gui/popup/popup_handler.go index 7c15c56ea..3b305a25d 100644 --- a/pkg/gui/popup/popup_handler.go +++ b/pkg/gui/popup/popup_handler.go @@ -19,7 +19,7 @@ type PopupHandler struct { currentContextFn func() types.Context createMenuFn func(types.CreateMenuOptions) error withWaitingStatusFn func(message string, f func(gocui.Task) error) - withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error) + withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error) toastFn func(message string, kind types.ToastKind) getPromptInputFn func() string inDemo func() bool @@ -35,7 +35,7 @@ func NewPopupHandler( currentContextFn func() types.Context, createMenuFn func(types.CreateMenuOptions) error, withWaitingStatusFn func(message string, f func(gocui.Task) error), - withWaitingStatusBlockingInputFn func(message string, f func(gocui.Task) error), + withWaitingStatusBlockingInputFn func(opts types.WaitingStatusOpts, f func(gocui.Task) error), toastFn func(message string, kind types.ToastKind), getPromptInputFn func() string, inDemo func() bool, @@ -76,8 +76,8 @@ func (self *PopupHandler) WithWaitingStatus(message string, f func(gocui.Task) e return nil } -func (self *PopupHandler) WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error { - self.withWaitingStatusBlockingInputFn(message, f) +func (self *PopupHandler) WithWaitingStatusBlockingInput(opts types.WaitingStatusOpts, f func(gocui.Task) error) error { + self.withWaitingStatusBlockingInputFn(opts, f) return nil } diff --git a/pkg/gui/types/common.go b/pkg/gui/types/common.go index 2b5fb0ac7..b2b924bc0 100644 --- a/pkg/gui/types/common.go +++ b/pkg/gui/types/common.go @@ -171,7 +171,7 @@ type IPopupHandler interface { // Shows a popup prompting the user for input. Prompt(opts PromptOpts) WithWaitingStatus(message string, f func(gocui.Task) error) error - WithWaitingStatusBlockingInput(message string, f func(gocui.Task) error) error + WithWaitingStatusBlockingInput(opts WaitingStatusOpts, f func(gocui.Task) error) error Menu(opts CreateMenuOptions) error Toast(message string) ErrorToast(message string) @@ -179,6 +179,20 @@ type IPopupHandler interface { GetPromptInput() string } +type WaitingStatusOpts struct { + // The message shown alongside the spinner while the operation runs. + Message string + + // When set, the working tree state mode (the yellow + // "Rebasing"/"Merging"/"Cherry-picking"/"Reverting" indicator, along with + // its abort button) stays hidden until the operation is done. Set it for + // operations that drive such a state themselves: the state they leave on + // disk while they run is transient, so surfacing it would flash the + // indicator on and offer to abort a sequence that lazygit is in the middle + // of running. + HideWorkingTreeState bool +} + type ToastKind int const ( From b30c7345139764cb13b86e2cd5c916fd6bd9cf5f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:18:55 +0200 Subject: [PATCH 4/6] Handle entering and leaving filtering mode in one place Setting a filter and clearing it are the same transition in opposite directions: mutate the mode, bring the screen mode in line with it, reload the views that depend on the filter, and put the selection somewhere sensible in the reloaded commit list. They were implemented twice, once in the filtering menu and once in ModeHelper, which is how the two came to repaint the commit list in different ways. Derive the screen mode and the panel switch from whether a filter is active after the change, so both directions fall out of the same code, and give ModeHelper the entry points for both. The filtering menu is left with nothing but the menu. --- pkg/gui/controllers/filtering_menu_action.go | 40 ++---------- pkg/gui/controllers/helpers/mode_helper.go | 64 +++++++++++++++++--- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/pkg/gui/controllers/filtering_menu_action.go b/pkg/gui/controllers/filtering_menu_action.go index 7ae26c4ef..0bd7ca902 100644 --- a/pkg/gui/controllers/filtering_menu_action.go +++ b/pkg/gui/controllers/filtering_menu_action.go @@ -3,7 +3,6 @@ package controllers import ( "fmt" - "github.com/jesseduffield/lazygit/pkg/gui/controllers/helpers" "github.com/jesseduffield/lazygit/pkg/gui/types" ) @@ -42,7 +41,7 @@ func (self *FilteringMenuAction) Call() error { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, fileName), OnPress: func() error { - return self.setFilteringPath(fileName) + return self.c.Helpers().Mode.SetFilteringPath(fileName) }, Tooltip: tooltip, }) @@ -52,7 +51,7 @@ func (self *FilteringMenuAction) Call() error { menuItems = append(menuItems, &types.MenuItem{ Label: fmt.Sprintf("%s '%s'", self.c.Tr.FilterBy, author), OnPress: func() error { - return self.setFilteringAuthor(author) + return self.c.Helpers().Mode.SetFilteringAuthor(author) }, Tooltip: tooltip, }) @@ -65,7 +64,7 @@ func (self *FilteringMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetFilePathSuggestionsFunc(), Title: self.c.Tr.EnterFileName, HandleConfirm: func(response string) error { - return self.setFilteringPath(response) + return self.c.Helpers().Mode.SetFilteringPath(response) }, }) @@ -81,7 +80,7 @@ func (self *FilteringMenuAction) Call() error { FindSuggestionsFunc: self.c.Helpers().Suggestions.GetAuthorsSuggestionsFunc(), Title: self.c.Tr.EnterAuthor, HandleConfirm: func(response string) error { - return self.setFilteringAuthor(response) + return self.c.Helpers().Mode.SetFilteringAuthor(response) }, }) @@ -99,34 +98,3 @@ func (self *FilteringMenuAction) Call() error { return self.c.Menu(types.CreateMenuOptions{Title: self.c.Tr.FilteringMenuTitle, Items: menuItems}) } - -func (self *FilteringMenuAction) setFilteringPath(path string) error { - self.c.Modes().Filtering.Reset() - self.c.Modes().Filtering.SetPath(path) - return self.setFiltering() -} - -func (self *FilteringMenuAction) setFilteringAuthor(author string) error { - self.c.Modes().Filtering.Reset() - self.c.Modes().Filtering.SetAuthor(author) - return self.setFiltering() -} - -func (self *FilteringMenuAction) setFiltering() error { - self.c.Modes().Filtering.SetSelectedCommitHash(self.c.Contexts().LocalCommits.GetSelectedCommitHash()) - - repoState := self.c.State().GetRepoState() - if repoState.GetScreenMode() == types.SCREEN_NORMAL { - repoState.SetScreenMode(types.SCREEN_HALF) - } - - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - - self.c.Refresh(types.RefreshOptions{Scope: helpers.ScopesToRefreshWhenFilteringModeChanges(), Then: func() error { - self.c.Contexts().LocalCommits.SetSelection(0) - self.c.Contexts().LocalCommits.HandleFocus(types.OnFocusOpts{}) - return nil - }}) - - return nil -} diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 50f35341b..8b88205e0 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -182,16 +182,40 @@ func (self *ModeHelper) ExitFilterMode() error { return self.ClearFiltering() } +func (self *ModeHelper) SetFilteringPath(path string) error { + return self.setFiltering(func() { + self.c.Modes().Filtering.SetPath(path) + }) +} + +func (self *ModeHelper) SetFilteringAuthor(author string) error { + return self.setFiltering(func() { + self.c.Modes().Filtering.SetAuthor(author) + }) +} + +func (self *ModeHelper) setFiltering(setFilter func()) error { + self.changeFiltering( + func() { + // Whatever we were filtering by before is replaced, not added to + self.c.Modes().Filtering.Reset() + setFilter() + self.c.Modes().Filtering.SetSelectedCommitHash( + self.c.Contexts().LocalCommits.GetSelectedCommitHash()) + }, + func() { + self.c.Contexts().LocalCommits.SetSelection(0) + }, + ) + return nil +} + func (self *ModeHelper) ClearFiltering() error { selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() - self.c.Modes().Filtering.Reset() - if self.c.State().GetRepoState().GetScreenMode() == types.SCREEN_HALF { - self.c.State().GetRepoState().SetScreenMode(types.SCREEN_NORMAL) - } - self.c.Refresh(types.RefreshOptions{ - Scope: ScopesToRefreshWhenFilteringModeChanges(), - Then: func() error { + self.changeFiltering( + self.c.Modes().Filtering.Reset, + func() { // Find the commit that was last selected in filtering mode, and select it again after refreshing if !self.c.Contexts().LocalCommits.SelectCommitByHash(selectedCommitHash) { // If we couldn't find it (either because no commit was selected @@ -200,12 +224,36 @@ func (self *ModeHelper) ClearFiltering() error { // before we entered filtering self.c.Contexts().LocalCommits.SelectCommitByHash(self.c.Modes().Filtering.GetSelectedCommitHash()) } + }, + ) + return nil +} +// changeFiltering applies a change to the filtering mode: setFilter mutates the +// mode, then the screen mode and the focused panel are brought in line with it, +// the views whose contents depend on the filter are reloaded, and selectCommit +// puts the selection where it belongs in the reloaded commit list. +func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) { + setFilter() + + repoState := self.c.State().GetRepoState() + if self.c.Modes().Filtering.Active() { + if repoState.GetScreenMode() == types.SCREEN_NORMAL { + repoState.SetScreenMode(types.SCREEN_HALF) + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + } else if repoState.GetScreenMode() == types.SCREEN_HALF { + repoState.SetScreenMode(types.SCREEN_NORMAL) + } + + self.c.Refresh(types.RefreshOptions{ + Scope: ScopesToRefreshWhenFilteringModeChanges(), + Then: func() error { + selectCommit() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) return nil }, }) - return nil } // Stashes really only need to be refreshed when filtering by path, not by author, but it's too much From 7e6d5ff7c1fc71452805055112c8ee739370c9d0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 07:29:22 +0200 Subject: [PATCH 5/6] Don't show an unfiltered list as if it were filtered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Entering or leaving filtering mode switched the screen mode and the focused panel immediately, then reloaded the commit list in the background. The result was an unfiltered list presented in the layout that says "you are filtering", with nothing to say that anything was still happening — and in a big repo that state can last seconds. Before we stopped blocking the UI thread on refreshes, the reload happened before any of it, so the two always agreed; the price was a frozen UI for the duration. Do neither: reload on a worker, so the UI stays live, and hold back everything the user can see of the change until the new lists are ready, so they still land together in one frame. A waiting status says what is going on in the meantime, and blocking input means the keys pressed while it runs arrive after the change rather than acting on a list that is about to be replaced. --- pkg/gui/controllers/helpers/mode_helper.go | 69 ++++++++++++++-------- pkg/i18n/english.go | 4 ++ 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 8b88205e0..68f7ea149 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "strings" + "github.com/jesseduffield/lazygit/pkg/gocui" "github.com/jesseduffield/lazygit/pkg/gui/style" "github.com/jesseduffield/lazygit/pkg/gui/types" "github.com/samber/lo" @@ -195,7 +196,7 @@ func (self *ModeHelper) SetFilteringAuthor(author string) error { } func (self *ModeHelper) setFiltering(setFilter func()) error { - self.changeFiltering( + return self.changeFiltering( func() { // Whatever we were filtering by before is replaced, not added to self.c.Modes().Filtering.Reset() @@ -207,13 +208,12 @@ func (self *ModeHelper) setFiltering(setFilter func()) error { self.c.Contexts().LocalCommits.SetSelection(0) }, ) - return nil } func (self *ModeHelper) ClearFiltering() error { selectedCommitHash := self.c.Contexts().LocalCommits.GetSelectedCommitHash() - self.changeFiltering( + return self.changeFiltering( self.c.Modes().Filtering.Reset, func() { // Find the commit that was last selected in filtering mode, and select it again after refreshing @@ -226,33 +226,54 @@ func (self *ModeHelper) ClearFiltering() error { } }, ) - return nil } // changeFiltering applies a change to the filtering mode: setFilter mutates the -// mode, then the screen mode and the focused panel are brought in line with it, -// the views whose contents depend on the filter are reloaded, and selectCommit -// puts the selection where it belongs in the reloaded commit list. -func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) { +// mode, then the views whose contents depend on the filter are reloaded, and +// selectCommit puts the selection where it belongs in the reloaded commit list. +// +// Reloading the commit list can take seconds in a big repo, so it happens on a +// worker with a waiting status. Everything the user can see of the change waits +// for it: the screen mode, the focused panel and the reloaded lists all land in +// the same frame, from the refresh's Then, rather than framing an unfiltered +// list as if it were the filtered one. Until then the pre-change state stays on +// screen, and it stays consistent, because the only thing that has changed +// behind it is the filter that the reload is in the middle of applying. The one +// thing that can't wait is the mode indicator in the information panel: the +// filter has to be set before the reload can use it, so the indicator leads the +// lists by however long the reload takes. +// +// Input is blocked for the duration: the keys the user presses arrive after the +// change, which is where they meant them to go, and it keeps a second filter +// change from racing this one — they would both refresh with whichever filter +// happened to be set when their git commands ran. +func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) error { setFilter() - repoState := self.c.State().GetRepoState() - if self.c.Modes().Filtering.Active() { - if repoState.GetScreenMode() == types.SCREEN_NORMAL { - repoState.SetScreenMode(types.SCREEN_HALF) - } - self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) - } else if repoState.GetScreenMode() == types.SCREEN_HALF { - repoState.SetScreenMode(types.SCREEN_NORMAL) - } + filtering := self.c.Modes().Filtering.Active() + message := lo.Ternary(filtering, self.c.Tr.ApplyingFilterStatus, self.c.Tr.RemovingFilterStatus) - self.c.Refresh(types.RefreshOptions{ - Scope: ScopesToRefreshWhenFilteringModeChanges(), - Then: func() error { - selectCommit() - self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) - return nil - }, + return self.c.WithWaitingStatusBlockingInput(types.WaitingStatusOpts{Message: message}, func(gocui.Task) error { + self.c.RefreshFromWorker(types.RefreshOptions{ + Scope: ScopesToRefreshWhenFilteringModeChanges(), + BatchUIUpdates: true, + Then: func() error { + repoState := self.c.State().GetRepoState() + if filtering { + if repoState.GetScreenMode() == types.SCREEN_NORMAL { + repoState.SetScreenMode(types.SCREEN_HALF) + } + self.c.Context().Push(self.c.Contexts().LocalCommits, types.OnFocusOpts{}) + } else if repoState.GetScreenMode() == types.SCREEN_HALF { + repoState.SetScreenMode(types.SCREEN_NORMAL) + } + + selectCommit() + self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) + return nil + }, + }) + return nil }) } diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index deb55d6e9..845155aed 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -441,6 +441,8 @@ type TranslationSet struct { ResettingStatus string CreatingFixupCommitStatus string MovingCommitsToNewBranchStatus string + ApplyingFilterStatus string + RemovingFilterStatus string CommitFiles string SubCommitsDynamicTitle string CommitFilesDynamicTitle string @@ -1598,6 +1600,8 @@ func EnglishTranslationSet() *TranslationSet { ResettingStatus: "Resetting", CreatingFixupCommitStatus: "Creating fixup commit", MovingCommitsToNewBranchStatus: "Moving commits to new branch", + ApplyingFilterStatus: "Applying filter", + RemovingFilterStatus: "Removing filter", CommitFiles: "Commit files", SubCommitsDynamicTitle: "Commits (%s)", CommitFilesDynamicTitle: "Diff files (%s)", From 1b901c7187d7d6d886c495d7bc441fcfc34f25bd Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Wed, 5 Aug 2026 11:57:15 +0200 Subject: [PATCH 6/6] Scroll the selection into view after a filtering mode change The commit list a filtering mode change leaves behind has nothing to do with the one that was showing, so the scroll position it inherits says nothing about where the selection ended up, and the selection can land anywhere off screen. PostRefreshUpdate only moves the cursor within the existing scroll position, so ask for the scroll separately, the way the commits refresh does when it moves the selection itself. Exiting filtering mode looked like it worked, but only by accident: the commits refresh recognizes the commit that was selected before it ran, selects it again at its new index, and scrolls because the index moved. That does nothing for the case where the commit is gone from the list, or for entering filtering mode, where we select the first commit ourselves. --- pkg/gui/controllers/helpers/mode_helper.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pkg/gui/controllers/helpers/mode_helper.go b/pkg/gui/controllers/helpers/mode_helper.go index 68f7ea149..6e5139744 100644 --- a/pkg/gui/controllers/helpers/mode_helper.go +++ b/pkg/gui/controllers/helpers/mode_helper.go @@ -270,6 +270,11 @@ func (self *ModeHelper) changeFiltering(setFilter func(), selectCommit func()) e selectCommit() self.c.PostRefreshUpdate(self.c.Contexts().LocalCommits) + // The list we just selected in has nothing to do with the one + // that was showing, so wherever it was scrolled to says nothing + // about where the selection now is. PostRefreshUpdate leaves the + // scroll position alone, so ask for it separately. + self.c.Contexts().LocalCommits.FocusLine(true) return nil }, })