From c63f17c4815c482ab32a91ee5d32b3ee0109fb1e Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 22:09:25 +0200 Subject: [PATCH 1/5] AGENTS.md additions --- AGENTS.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 96c5ba75871..d621604be0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -390,12 +390,32 @@ Avoid phrasings like: - "cleaner than the previous approach" - "we used to ... but ..." - "after trying X, we found Y" +- "X rather than Y", where Y is what the code did before the change The iteration story is sometimes worth preserving — but it belongs in the commit message, which is the durable record of *why this change was made*. The code comment should make sense to someone who has never seen any prior version and is just trying to understand the file as it currently exists. +The tell is subtler than an explicit "we used to". A comment that justifies the +code against an alternative — "run it on a worker rather than blocking the UI", +"switch panels in `Then` rather than a moment earlier" — is history in disguise +whenever that alternative is what the code did before the change. It reads as +ordinary rationale, but the reader has no way to know the contrast is with a +version that no longer exists. + +So the check to apply is: would you have written this comment if you were +writing the file from scratch, with no diff in mind? If not, the sentence +belongs in the commit message. + +## Don't justify routine call sites + +If the codebase calls a helper in twenty places without explanation, your +twenty-first call site doesn't need one either. A comment there says "something +here is unusual"; when nothing is, it's noise — and it invites exactly the kind +of before/after justification the section above warns about. Look at the +neighboring call sites before writing one: if they're bare, match them. + ## Don't present "live with the bug" as an option When you're investigating a defect and laying out fix options for the user, From 46b9a1f47b2f3f94aa7d572cc466cb427a48cfea Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 21:24:57 +0200 Subject: [PATCH 2/5] Apply the panel updates after stash operations in a single frame Stashing and popping change both the stash list and the files list. With each scope updating the UI as soon as its own refresh is done, the two panels visibly change at different times; with gui.shrinkSidePanelsToContent that also means their sizes change at different times than their contents. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/files_controller.go | 5 ++++- pkg/gui/controllers/stash_controller.go | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 567b0b6e5b6..62751eb3549 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1513,7 +1513,10 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro if err := stashFunc(stashComment); err != nil { return err } - self.c.Refresh(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) + self.c.Refresh(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + }) return nil }, AllowEmptyInput: true, diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index d01fc8dbf31..94906846037 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -199,7 +199,10 @@ func (self *StashController) postStashRefresh() { // the remaining stash entries, and acting on the next entry in quick // succession (confirming the popup and pressing the key again right away) // must see the refreshed list, or it would target the wrong stash. - self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH, types.FILES}}) + self.c.RefreshBlockingInput(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + }) } func (self *StashController) handleNewBranchOffStashEntry(stashEntry *models.StashEntry) error { From fc3ee6cddea4cce3fb69ac76b7fb444b33fc67b0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 21:26:20 +0200 Subject: [PATCH 3/5] Switch to the files panel from the post-stash refresh's Then Pushing the files context right after kicking off the refresh moves the focus (and, with gui.shrinkSidePanelsToContent, resizes the panels) a frame before the refreshed stash and files lists arrive. Doing it from Then puts it in the same frame as the data it belongs to. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/stash_controller.go | 32 ++++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 94906846037..65de7f2433b 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -122,14 +122,8 @@ func (self *StashController) handleStashApply(stashEntry *models.StashEntry) err HandleConfirm: func() error { self.c.LogAction(self.c.Tr.Actions.ApplyStash) err := self.c.Git().Stash.Apply(stashEntry.Index) - self.postStashRefresh() - if err != nil { - return err - } - if self.c.UserConfig().Gui.SwitchToFilesAfterStashApply { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply) + return err }, }) } @@ -139,14 +133,8 @@ func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error self.c.LogAction(self.c.Tr.Actions.PopStash) self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) err := self.c.Git().Stash.Pop(stashEntry.Index) - self.postStashRefresh() - if err != nil { - return err - } - if self.c.UserConfig().Gui.SwitchToFilesAfterStashPop { - self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) - } - return nil + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop) + return err } if self.c.UserConfig().Gui.SkipStashWarning { @@ -194,7 +182,9 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) return nil } -func (self *StashController) postStashRefresh() { +// postStashRefresh refreshes the panels that applying or popping a stash +// affects, moving the focus to the files panel if switchToFiles is set. +func (self *StashController) postStashRefresh(switchToFiles bool) { // Block input until the refresh has landed: popping shifts the indices of // the remaining stash entries, and acting on the next entry in quick // succession (confirming the popup and pressing the key again right away) @@ -202,6 +192,14 @@ func (self *StashController) postStashRefresh() { self.c.RefreshBlockingInput(types.RefreshOptions{ BatchUIUpdates: true, Scope: []types.RefreshableView{types.STASH, types.FILES}, + Then: func() error { + // Switch panels from here, so that the focus change lands in the + // same frame as the refreshed panel contents. + if switchToFiles { + self.c.Context().Push(self.c.Contexts().Files, types.OnFocusOpts{}) + } + return nil + }, }) } From 67dee4619541a26c10e626bce508fe21641106b6 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 6 Aug 2026 21:28:33 +0200 Subject: [PATCH 4/5] Collapse the stash range selection from the refresh's Then Collapsing the range before kicking off the refresh paints the new selection against the list as it was before the drop, so for a frame the entries that were just dropped are still on screen (and, with gui.shrinkSidePanelsToContent, the panel is still at its old size). Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/stash_controller.go | 32 ++++++++++++++++++------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 65de7f2433b..5d78a84ca7e 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -163,19 +163,33 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) // iteration lets the workers race and an earlier, stale result can // land last. The indices are captured up front and we drop // highest-first, so the remaining lower indices stay valid without - // an intervening refresh. Block input until the refresh has - // landed, so that dropping the next entry in quick succession - // (confirming and pressing the key again right away) sees the - // refreshed list and not the stale, pre-drop indices. - defer self.c.RefreshBlockingInput(types.RefreshOptions{Scope: []types.RefreshableView{types.STASH}}) + // an intervening refresh. + var dropErr error for i := len(stashEntries) - 1; i >= 0; i-- { self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.DroppingStash, stashEntries[i].Hash), false) - if err := self.c.Git().Stash.Drop(stashEntries[i].Index); err != nil { - return err + if dropErr = self.c.Git().Stash.Drop(stashEntries[i].Index); dropErr != nil { + break } } - self.context().CollapseRangeSelectionToTop() - return nil + // Block input until the refresh has landed, so that dropping the + // next entry in quick succession (confirming and pressing the key + // again right away) sees the refreshed list and not the stale, + // pre-drop indices. + self.c.RefreshBlockingInput(types.RefreshOptions{ + Scope: []types.RefreshableView{types.STASH}, + Then: func() error { + // Collapse the range selection from here, so that it lands + // in the same frame as the shortened list. The refresh has + // painted the list by the time Then runs, so the new + // selection needs a focus update of its own. + if dropErr == nil { + self.context().CollapseRangeSelectionToTop() + self.context().HandleFocus(types.OnFocusOpts{}) + } + return nil + }, + }) + return dropErr }, }) From 52969523d59acd45f44957a5d5986afce9452d85 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Fri, 7 Aug 2026 06:54:55 +0200 Subject: [PATCH 5/5] Run the stash operations with a waiting status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating and applying a stash both touch every changed file, so in a large repo they can take long enough to be noticeable — and running them on the UI thread meant the confirmation popup stayed on screen, frozen, for the whole operation. Run them on a worker instead, with a spinner, and keep blocking input for their duration so that the type-ahead guarantee the refresh used to provide still holds. Dropping stays on the UI thread: it only rewrites the stash reflog, so it's fast no matter how big the stashes are. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/gui/controllers/files_controller.go | 22 ++++++++------ pkg/gui/controllers/stash_controller.go | 39 ++++++++++++++++--------- pkg/i18n/english.go | 6 ++++ 3 files changed, 44 insertions(+), 23 deletions(-) diff --git a/pkg/gui/controllers/files_controller.go b/pkg/gui/controllers/files_controller.go index 62751eb3549..be8e9447708 100644 --- a/pkg/gui/controllers/files_controller.go +++ b/pkg/gui/controllers/files_controller.go @@ -1508,16 +1508,20 @@ func (self *FilesController) handleStashSave(stashFunc func(message string) erro self.c.Prompt(types.PromptOpts{ Title: self.c.Tr.StashChanges, HandleConfirm: func(stashComment string) error { - self.c.LogAction(action) + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.StashingStatus}, + func(gocui.Task) error { + self.c.LogAction(action) - if err := stashFunc(stashComment); err != nil { - return err - } - self.c.Refresh(types.RefreshOptions{ - BatchUIUpdates: true, - Scope: []types.RefreshableView{types.STASH, types.FILES}, - }) - return nil + if err := stashFunc(stashComment); err != nil { + return err + } + self.c.RefreshFromWorker(types.RefreshOptions{ + BatchUIUpdates: true, + Scope: []types.RefreshableView{types.STASH, types.FILES}, + }) + return nil + }) }, AllowEmptyInput: true, }) diff --git a/pkg/gui/controllers/stash_controller.go b/pkg/gui/controllers/stash_controller.go index 5d78a84ca7e..03011e421c2 100644 --- a/pkg/gui/controllers/stash_controller.go +++ b/pkg/gui/controllers/stash_controller.go @@ -4,6 +4,7 @@ import ( "fmt" "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/style" "github.com/jesseduffield/lazygit/pkg/gui/types" @@ -120,21 +121,29 @@ func (self *StashController) handleStashApply(stashEntry *models.StashEntry) err Title: self.c.Tr.StashApply, Prompt: self.c.Tr.SureApplyStashEntry, HandleConfirm: func() error { - self.c.LogAction(self.c.Tr.Actions.ApplyStash) - err := self.c.Git().Stash.Apply(stashEntry.Index) - self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply) - return err + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.ApplyingStashStatus}, + func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.ApplyStash) + err := self.c.Git().Stash.Apply(stashEntry.Index) + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashApply) + return err + }) }, }) } func (self *StashController) handleStashPop(stashEntry *models.StashEntry) error { pop := func() error { - self.c.LogAction(self.c.Tr.Actions.PopStash) - self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) - err := self.c.Git().Stash.Pop(stashEntry.Index) - self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop) - return err + return self.c.WithWaitingStatusBlockingInput( + types.WaitingStatusOpts{Message: self.c.Tr.PoppingStashStatus}, + func(gocui.Task) error { + self.c.LogAction(self.c.Tr.Actions.PopStash) + self.c.LogCommand(fmt.Sprintf(self.c.Tr.Log.PoppingStash, stashEntry.Hash), false) + err := self.c.Git().Stash.Pop(stashEntry.Index) + self.postStashRefresh(err == nil && self.c.UserConfig().Gui.SwitchToFilesAfterStashPop) + return err + }) } if self.c.UserConfig().Gui.SkipStashWarning { @@ -198,12 +207,14 @@ func (self *StashController) handleStashDrop(stashEntries []*models.StashEntry) // postStashRefresh refreshes the panels that applying or popping a stash // affects, moving the focus to the files panel if switchToFiles is set. +// +// Call it from the worker that ran the stash command, from inside a +// WithWaitingStatusBlockingInput: popping shifts the indices of the remaining +// stash entries, so acting on the next entry in quick succession (confirming +// the popup and pressing the key again right away) has to be held off until +// the refreshed list is in place, or it would target the wrong stash. func (self *StashController) postStashRefresh(switchToFiles bool) { - // Block input until the refresh has landed: popping shifts the indices of - // the remaining stash entries, and acting on the next entry in quick - // succession (confirming the popup and pressing the key again right away) - // must see the refreshed list, or it would target the wrong stash. - self.c.RefreshBlockingInput(types.RefreshOptions{ + self.c.RefreshFromWorker(types.RefreshOptions{ BatchUIUpdates: true, Scope: []types.RefreshableView{types.STASH, types.FILES}, Then: func() error { diff --git a/pkg/i18n/english.go b/pkg/i18n/english.go index 845155aedc0..009a78d334b 100644 --- a/pkg/i18n/english.go +++ b/pkg/i18n/english.go @@ -443,6 +443,9 @@ type TranslationSet struct { MovingCommitsToNewBranchStatus string ApplyingFilterStatus string RemovingFilterStatus string + StashingStatus string + ApplyingStashStatus string + PoppingStashStatus string CommitFiles string SubCommitsDynamicTitle string CommitFilesDynamicTitle string @@ -1602,6 +1605,9 @@ func EnglishTranslationSet() *TranslationSet { MovingCommitsToNewBranchStatus: "Moving commits to new branch", ApplyingFilterStatus: "Applying filter", RemovingFilterStatus: "Removing filter", + StashingStatus: "Stashing", + ApplyingStashStatus: "Applying stash", + PoppingStashStatus: "Popping stash", CommitFiles: "Commit files", SubCommitsDynamicTitle: "Commits (%s)", CommitFilesDynamicTitle: "Diff files (%s)",