From a92f739dc12613be47b085f4eda7b913cfe48985 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:37:13 +0100 Subject: [PATCH 1/7] Add a test showing that a refresh changes the current search match Co-Authored-By: Claude Opus 5.5 --- ...arch_keeps_the_current_match_on_refresh.go | 66 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 67 insertions(+) create mode 100644 pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go diff --git a/pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go b/pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go new file mode 100644 index 000000000..eb5619887 --- /dev/null +++ b/pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go @@ -0,0 +1,66 @@ +package commit + +import ( + "fmt" + + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchKeepsTheCurrentMatchOnRefresh = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With the selection between two search matches, a refresh keeps the match that the selection made the current one", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // So that R refreshes in the commits panel instead of rewording the commit + cfg.GetUserConfig().Keybinding.Commits.RenameCommitWithEditor = config.Keybinding{""} + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("branch") + for i := 1; i <= 8; i++ { + message := fmt.Sprintf("commit %02d", i) + if i == 7 || i == 3 { + message += " needle" + } + shell.EmptyCommit(message) + } + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + FilterOrSearch("needle"). + SelectedLines( + Contains("commit 07 needle"), + ). + NavigateToLine(Contains("commit 05")). + Tap(func() { + t.Views().Search().Content(Contains("matches for 'needle' (1 of 2)")) + + t.GlobalPress(keys.Universal.Refresh) + t.Views().Search().Content( + /* EXPECTED: + Contains("matches for 'needle' (1 of 2)")) + ACTUAL: */ + Contains("matches for 'needle' (2 of 2)")) + }). + NavigateToLine(Contains("commit 01")). + Tap(func() { + t.Views().Search().Content(Contains("matches for 'needle' (2 of 2)")) + + t.GlobalPress(keys.Universal.Refresh) + t.Views().Search().Content( + /* EXPECTED: + Contains("matches for 'needle' (2 of 2)")) + ACTUAL: */ + Contains("matches for 'needle' (1 of 2)")) + }). + // The next match wraps around to the first one + Press(keys.Universal.NextMatch). + SelectedLines( + /* EXPECTED: + Contains("commit 07 needle"), + ACTUAL: */ + Contains("commit 03 needle"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 559816011..d6e5be26a 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -174,6 +174,7 @@ var tests = []*components.IntegrationTest{ commit.RevertWithConflictSingleCommit, commit.Reword, commit.Search, + commit.SearchKeepsTheCurrentMatchOnRefresh, commit.SetAuthor, commit.SetAuthorRange, commit.StageRangeOfLines, From 6878890b9641528af36be1c6a3a5c0ece541ea6d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:38:40 +0100 Subject: [PATCH 2/7] Give View.Search its own choice of the match to start at A new search goes to the first match after the cursor. A search that runs again because the content has changed uses the same rule, since Search shares it with UpdateSearchResults. Choose the match in Search itself, so that UpdateSearchResults can choose differently. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view.go | 56 ++++++++++++++++++++++++++++++++--------------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index f1c708796..89e938a56 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -575,34 +575,54 @@ func (v *View) UpdateSearchResults(str string, modelSearchResults []SearchPositi v.searcher.search(str, modelSearchResults) v.updateSearchPositions() - if len(v.searcher.searchPositions) > 0 { - // get the first result past the current cursor - currentIndex := 0 - if v.Highlight { - // ...but only if we're showing the highlighted line - adjustedY := v.oy + v.cy - adjustedX := v.ox + v.cx - for i, pos := range v.searcher.searchPositions { - if pos.Y > adjustedY || (pos.Y == adjustedY && pos.XStart > adjustedX) { - currentIndex = i - break - } - } - } - v.searcher.currentSearchIndex = currentIndex + if v.Highlight && len(v.searcher.searchPositions) > 0 { + v.searcher.currentSearchIndex = v.firstSearchPositionAfterCursor() } } +// Search starts a search for str and goes to its first match after the cursor. See +// UpdateSearchResults for what modelSearchResults is. func (v *View) Search(str string, modelSearchResults []SearchPosition) { - v.UpdateSearchResults(str, modelSearchResults) - - if len(v.searcher.searchPositions) > 0 { + if v.startSearch(str, modelSearchResults) { v.SelectSearchResult(v.searcher.currentSearchIndex) } else { v.renderSearchStatus(0, 0) } } +// startSearch runs a new search for str and makes its first match after the cursor the +// current one. It returns whether there are any matches. +func (v *View) startSearch(str string, modelSearchResults []SearchPosition) bool { + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.searcher.search(str, modelSearchResults) + v.updateSearchPositions() + + if len(v.searcher.searchPositions) == 0 { + return false + } + // If the view doesn't show a selection, the search starts at the first match. + if v.Highlight { + v.searcher.currentSearchIndex = v.firstSearchPositionAfterCursor() + } + return true +} + +// firstSearchPositionAfterCursor returns the first match after the cursor, counting one +// on the cursor's line if it starts right of the cursor. If there is none, it returns +// the first match, so that the search wraps around. +func (v *View) firstSearchPositionAfterCursor() int { + cursorY := v.oy + v.cy + cursorX := v.ox + v.cx + for i, pos := range v.searcher.searchPositions { + if pos.Y > cursorY || (pos.Y == cursorY && pos.XStart > cursorX) { + return i + } + } + return 0 +} + func (v *View) ClearSearch() { v.searcher.clearSearch() } From c732aaf1633b112641fc3b1f298e3d82780f382f Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:40:09 +0100 Subject: [PATCH 3/7] Choose the current search match after a refresh as after moving the selection If the selection is between two search matches, moving it makes the match above it the current one, but a refresh makes the match below it the current one. So a background refresh changes the "x of y" status and the highlighted match although the user did nothing. If the selection is below the last match, a refresh makes the first match the current one; n then skips that match and goes to the second one. After a refresh, choose the current match in the same way as after moving the selection. This makes the first match on the selected line the current one, or else the last match above it. The same applies when the focused main view renders its diff again. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view.go | 6 +++++- .../commit/search_keeps_the_current_match_on_refresh.go | 9 --------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 89e938a56..a45ac5c53 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -557,6 +557,10 @@ func (v *View) GetSearchStatus() (int, int) { return v.searcher.currentSearchIndex, len(v.searcher.searchPositions) } +// UpdateSearchResults runs the search for str again, for content or model search results +// that have changed. It doesn't move the view. The match at the cursor becomes the +// current one, in the same way as when the cursor moves; see SetNearestSearchPosition. +// // modelSearchResults is optional; pass nil to search the view. If non-nil, // these positions will be used for highlighting search results. Even in this // case the view will still be searched on a per-line basis, so that the caller @@ -576,7 +580,7 @@ func (v *View) UpdateSearchResults(str string, modelSearchResults []SearchPositi v.updateSearchPositions() if v.Highlight && len(v.searcher.searchPositions) > 0 { - v.searcher.currentSearchIndex = v.firstSearchPositionAfterCursor() + v.searcher.currentSearchIndex = v.nearestSearchPosition() } } diff --git a/pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go b/pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go index eb5619887..c81dc3833 100644 --- a/pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go +++ b/pkg/integration/tests/commit/search_keeps_the_current_match_on_refresh.go @@ -38,10 +38,7 @@ var SearchKeepsTheCurrentMatchOnRefresh = NewIntegrationTest(NewIntegrationTestA t.GlobalPress(keys.Universal.Refresh) t.Views().Search().Content( - /* EXPECTED: Contains("matches for 'needle' (1 of 2)")) - ACTUAL: */ - Contains("matches for 'needle' (2 of 2)")) }). NavigateToLine(Contains("commit 01")). Tap(func() { @@ -49,18 +46,12 @@ var SearchKeepsTheCurrentMatchOnRefresh = NewIntegrationTest(NewIntegrationTestA t.GlobalPress(keys.Universal.Refresh) t.Views().Search().Content( - /* EXPECTED: Contains("matches for 'needle' (2 of 2)")) - ACTUAL: */ - Contains("matches for 'needle' (1 of 2)")) }). // The next match wraps around to the first one Press(keys.Universal.NextMatch). SelectedLines( - /* EXPECTED: Contains("commit 07 needle"), - ACTUAL: */ - Contains("commit 03 needle"), ) }, }) From 5f5d6a1dd6f262a8489d8ac01b00e984e2c686e9 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:44:07 +0100 Subject: [PATCH 4/7] Add a test showing that a refresh resets the search in a commit log Co-Authored-By: Claude Opus 5.5 --- ..._log_keeps_the_current_match_on_refresh.go | 37 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 38 insertions(+) create mode 100644 pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go diff --git a/pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go b/pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go new file mode 100644 index 000000000..b15c8e2eb --- /dev/null +++ b/pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go @@ -0,0 +1,37 @@ +package main_view + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchOverACommitLogKeepsTheCurrentMatchOnRefresh = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "Searching a branch's commit log in the main view, which shows no selection, keeps the current match when a refresh renders the log again", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(config *config.AppConfig) {}, + SetupRepo: func(shell *Shell) { + shell.CreateNCommits(3) + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Branches(). + Focus(). + Press(keys.Universal.FocusMainView) + + t.Views().Main(). + IsFocused(). + SelectionIsHidden(). + FilterOrSearch("commit-0"). + Press(keys.Universal.NextMatch). + Tap(func() { + t.Views().Search().Content(Contains("matches for 'commit-0' (2 of 3)")) + + t.GlobalPress(keys.Universal.Refresh) + t.Views().Search().Content( + /* EXPECTED: + Contains("matches for 'commit-0' (2 of 3)")) + ACTUAL: */ + Contains("matches for 'commit-0' (1 of 3)")) + }) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index d6e5be26a..6557641de 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -503,6 +503,7 @@ var tests = []*components.IntegrationTest{ main_view.ScrollDiffHorizontally, main_view.SearchCollapsesTheSelection, main_view.SearchFollowsTheSelection, + main_view.SearchOverACommitLogKeepsTheCurrentMatchOnRefresh, main_view.SearchScrollsToAMatchOffTheEdge, main_view.SelectBelowALongCommitMessage, main_view.SelectBelowALongDiffstat, From 4aed79b9542e2b0093f889d1be0d0d8c543f674d Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:45:34 +0100 Subject: [PATCH 5/7] Keep the current search match when a view without a selection refreshes If a view doesn't show a selection, for example a branch's commit log in the focused main view, n and N step through the matches without moving a cursor. A refresh then resets the search to the first match, so the "x of y" status goes back to "1 of y", and the next n starts over from the top. Only reset the current match when a new search starts, and keep it when the search runs again over refreshed content. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view.go | 8 ++++++-- ...ver_a_commit_log_keeps_the_current_match_on_refresh.go | 3 --- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index a45ac5c53..4395c7a21 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -560,6 +560,7 @@ func (v *View) GetSearchStatus() (int, int) { // UpdateSearchResults runs the search for str again, for content or model search results // that have changed. It doesn't move the view. The match at the cursor becomes the // current one, in the same way as when the cursor moves; see SetNearestSearchPosition. +// If the view doesn't show a selection, the current match stays the same. // // modelSearchResults is optional; pass nil to search the view. If non-nil, // these positions will be used for highlighting search results. Even in this @@ -606,9 +607,11 @@ func (v *View) startSearch(str string, modelSearchResults []SearchPosition) bool if len(v.searcher.searchPositions) == 0 { return false } - // If the view doesn't show a selection, the search starts at the first match. if v.Highlight { v.searcher.currentSearchIndex = v.firstSearchPositionAfterCursor() + } else { + // If the view doesn't show a selection, the search starts at the first match. + v.searcher.currentSearchIndex = 0 } return true } @@ -726,11 +729,12 @@ func calculateNewOrigin(selectedLine int, oldOrigin int, lineCount int, viewHeig return oldOrigin } +// search sets what to search for. It leaves the current match for the caller to choose +// once the positions have been worked out. func (s *searcher) search(str string, modelSearchResults []SearchPosition) { s.searchString = str s.searchPositions = []SearchPosition{} s.modelSearchResults = modelSearchResults - s.currentSearchIndex = 0 } func (s *searcher) clearSearch() { diff --git a/pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go b/pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go index b15c8e2eb..fc63159bb 100644 --- a/pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go +++ b/pkg/integration/tests/main_view/search_over_a_commit_log_keeps_the_current_match_on_refresh.go @@ -28,10 +28,7 @@ var SearchOverACommitLogKeepsTheCurrentMatchOnRefresh = NewIntegrationTest(NewIn t.GlobalPress(keys.Universal.Refresh) t.Views().Search().Content( - /* EXPECTED: Contains("matches for 'commit-0' (2 of 3)")) - ACTUAL: */ - Contains("matches for 'commit-0' (1 of 3)")) }) }, }) From 029f939252334295ceb65dd445f0783854a916d0 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:45:59 +0100 Subject: [PATCH 6/7] Add a test showing that a refresh loses the second match on a line Co-Authored-By: Claude Opus 5.5 --- ...s_the_second_match_on_a_line_on_refresh.go | 53 +++++++++++++++++++ pkg/integration/tests/test_list.go | 1 + 2 files changed, 54 insertions(+) create mode 100644 pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go diff --git a/pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go b/pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go new file mode 100644 index 000000000..c16ae0e78 --- /dev/null +++ b/pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go @@ -0,0 +1,53 @@ +package commit + +import ( + "github.com/jesseduffield/lazygit/pkg/config" + . "github.com/jesseduffield/lazygit/pkg/integration/components" +) + +var SearchKeepsTheSecondMatchOnALineOnRefresh = NewIntegrationTest(NewIntegrationTestArgs{ + Description: "With the second of two search matches on the selected commit as the current one, a refresh keeps it current", + ExtraCmdArgs: []string{}, + Skip: false, + SetupConfig: func(cfg *config.AppConfig) { + // So that R refreshes in the commits panel instead of rewording the commit + cfg.GetUserConfig().Keybinding.Commits.RenameCommitWithEditor = config.Keybinding{""} + }, + SetupRepo: func(shell *Shell) { + shell.NewBranch("branch") + shell.EmptyCommit("commit 01") + shell.EmptyCommit("commit 02 needle") + shell.EmptyCommit("commit 03") + shell.EmptyCommit("commit 04 needle and needle") + shell.EmptyCommit("commit 05") + }, + Run: func(t *TestDriver, keys config.KeybindingConfig) { + t.Views().Commits(). + Focus(). + FilterOrSearch("needle"). + SelectedLines( + Contains("commit 04 needle and needle"), + ). + Press(keys.Universal.NextMatch). + SelectedLines( + Contains("commit 04 needle and needle"), + ). + Tap(func() { + t.Views().Search().Content(Contains("matches for 'needle' (2 of 3)")) + + t.GlobalPress(keys.Universal.Refresh) + t.Views().Search().Content( + /* EXPECTED: + Contains("matches for 'needle' (2 of 3)")) + ACTUAL: */ + Contains("matches for 'needle' (1 of 3)")) + }). + Press(keys.Universal.NextMatch). + SelectedLines( + /* EXPECTED: + Contains("commit 02 needle"), + ACTUAL: */ + Contains("commit 04 needle and needle"), + ) + }, +}) diff --git a/pkg/integration/tests/test_list.go b/pkg/integration/tests/test_list.go index 6557641de..6f08a6863 100644 --- a/pkg/integration/tests/test_list.go +++ b/pkg/integration/tests/test_list.go @@ -175,6 +175,7 @@ var tests = []*components.IntegrationTest{ commit.Reword, commit.Search, commit.SearchKeepsTheCurrentMatchOnRefresh, + commit.SearchKeepsTheSecondMatchOnALineOnRefresh, commit.SetAuthor, commit.SetAuthorRange, commit.StageRangeOfLines, From 8fb62f85b6d9c5430ec61a7ef02302bb1994e750 Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Sat, 7 Nov 2026 17:47:05 +0100 Subject: [PATCH 7/7] Keep the current search match when it is on the selected line If the selected line has several search matches and n has gone to the second one, a refresh makes the first match on the line the current one again. The "x of y" status changes although the user did nothing, and the next n goes to the second match on the same line once more. Focusing the view again does the same. If the current match is on the selected line, keep it. Co-Authored-By: Claude Opus 5.5 --- pkg/gocui/view.go | 7 +++++++ .../search_keeps_the_second_match_on_a_line_on_refresh.go | 6 ------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/pkg/gocui/view.go b/pkg/gocui/view.go index 4395c7a21..643163a2a 100644 --- a/pkg/gocui/view.go +++ b/pkg/gocui/view.go @@ -638,8 +638,15 @@ func (v *View) IsSearching() bool { return v.searcher.searchString != "" } +// nearestSearchPosition returns the current match if it is on the cursor's line. +// Otherwise it returns the first match on that line, or else the last match above it, +// or the first match if there is none above. func (v *View) nearestSearchPosition() int { currentLineIndex := v.cy + v.oy + if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y == currentLineIndex { + return v.searcher.currentSearchIndex + } + lastSearchPos := 0 for i, pos := range v.searcher.searchPositions { if pos.Y == currentLineIndex { diff --git a/pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go b/pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go index c16ae0e78..dc66e9e75 100644 --- a/pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go +++ b/pkg/integration/tests/commit/search_keeps_the_second_match_on_a_line_on_refresh.go @@ -37,17 +37,11 @@ var SearchKeepsTheSecondMatchOnALineOnRefresh = NewIntegrationTest(NewIntegratio t.GlobalPress(keys.Universal.Refresh) t.Views().Search().Content( - /* EXPECTED: Contains("matches for 'needle' (2 of 3)")) - ACTUAL: */ - Contains("matches for 'needle' (1 of 3)")) }). Press(keys.Universal.NextMatch). SelectedLines( - /* EXPECTED: Contains("commit 02 needle"), - ACTUAL: */ - Contains("commit 04 needle and needle"), ) }, })