From da7ca77c9db1c683e929f006f2582b055c08fb66 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 96c5ba758..d621604be 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 544f3b834b10632962a9d325a61e15a6290af069 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 567b0b6e5..62751eb35 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 d01fc8dbf..949068460 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 6c567d1eb6ce7fae3ac1ecc61e8f2238f26ec78e 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 949068460..65de7f243 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 ed22322ec84019c32772d95577de8049ae3f1653 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 65de7f243..5d78a84ca 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 4c39b0b90367126bbde5e4e00388e33bcd520c9c 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 62751eb35..be8e94477 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 5d78a84ca..03011e421 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 f71e61adb..6d41d31d6 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 @@ -1603,6 +1606,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)",