diff --git a/go.mod b/go.mod index 6ce52869a7a..4b7c77a3cf1 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/integrii/flaggy v1.4.0 github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd - github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985 + github.com/jesseduffield/gocui v0.3.1-0.20260104174656-7b510338b235 github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 github.com/karimkhaleel/jsonschema v0.0.0-20231001195015-d933f0d94ea3 diff --git a/go.sum b/go.sum index 3097536e7ed..47f96bf99a4 100644 --- a/go.sum +++ b/go.sum @@ -189,8 +189,8 @@ github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c h1:tC2Paiis github.com/jesseduffield/generics v0.0.0-20250517122708-b0b4a53a6f5c/go.mod h1:F2fEBk0ddf6ixrBrJjY7phfQ3hL9rXG0uSjvwYe50bE= github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd h1:ViKj6qth8FgcIWizn9KiACWwPemWSymx62OPN0tHT+Q= github.com/jesseduffield/go-git/v5 v5.14.1-0.20250407170251-e1a013310ccd/go.mod h1:lRhCiBr6XjQrvcQVa+UYsy/99d3wMXn/a0nSQlhnhlA= -github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985 h1:qdjGSiNnlGtoi+nzyERQJvee50JpJjeQ6sEhP7jCfMo= -github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985/go.mod h1:lQCd2TvvNXVKFBowy4A7xxZbUp+1KEiGs4j0Q5Zt9gQ= +github.com/jesseduffield/gocui v0.3.1-0.20260104174656-7b510338b235 h1:1MjdFm1rUneE1eMYeRkAA3kXswY+h5eLhgJFaZQs9j0= +github.com/jesseduffield/gocui v0.3.1-0.20260104174656-7b510338b235/go.mod h1:lQCd2TvvNXVKFBowy4A7xxZbUp+1KEiGs4j0Q5Zt9gQ= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5 h1:CDuQmfOjAtb1Gms6a1p5L2P8RhbLUq5t8aL7PiQd2uY= github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5/go.mod h1:qxN4mHOAyeIDLP7IK7defgPClM/z1Kze8VVQiaEjzsQ= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= diff --git a/pkg/gui/context/commit_files_context.go b/pkg/gui/context/commit_files_context.go index 52807faa9e4..b4a14045cb2 100644 --- a/pkg/gui/context/commit_files_context.go +++ b/pkg/gui/context/commit_files_context.go @@ -68,7 +68,8 @@ func NewCommitFilesContext(c *ContextCommon) *CommitFilesContext { }, } - ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) + ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) + ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect) return ctx } diff --git a/pkg/gui/context/list_context_trait.go b/pkg/gui/context/list_context_trait.go index f04c24ffeca..98833fdb2f7 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -24,6 +24,10 @@ type ListContextTrait struct { // If renderOnlyVisibleLines is true, needRerenderVisibleLines indicates whether we need to // rerender the visible lines e.g. because the scroll position changed needRerenderVisibleLines bool + + // true if we're inside the OnSearchSelect call; in that case we don't want to update the search + // result index. + inOnSearchSelect bool } func (self *ListContextTrait) IsListContext() {} @@ -31,6 +35,10 @@ func (self *ListContextTrait) IsListContext() {} func (self *ListContextTrait) FocusLine(scrollIntoView bool) { self.Context.FocusLine(scrollIntoView) + // Need to capture this in a local variable because by the time the AfterLayout function runs, + // the field will have been reset to false already + inOnSearchSelect := self.inOnSearchSelect + // Doing this at the end of the layout function because we need the view to be // resized before we focus the line, otherwise if we're in accordion mode // the view could be squashed and won't know how to adjust the cursor/origin. @@ -40,6 +48,9 @@ func (self *ListContextTrait) FocusLine(scrollIntoView bool) { self.GetViewTrait().FocusPoint( self.ModelIndexToViewIndex(self.list.GetSelectedLineIdx()), scrollIntoView) + if !inOnSearchSelect { + self.GetView().SetNearestSearchPosition() + } selectRangeIndex, isSelectingRange := self.list.GetRangeStartIdx() if isSelectingRange { @@ -117,10 +128,11 @@ func (self *ListContextTrait) HandleRender() { self.setFooter() } -func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) error { +func (self *ListContextTrait) OnSearchSelect(selectedLineIdx int) { self.GetList().SetSelection(self.ViewIndexToModelIndex(selectedLineIdx)) + self.inOnSearchSelect = true self.HandleFocus(types.OnFocusOpts{}) - return nil + self.inOnSearchSelect = false } func (self *ListContextTrait) IsItemVisible(item types.HasUrn) bool { diff --git a/pkg/gui/context/local_commits_context.go b/pkg/gui/context/local_commits_context.go index e873663c30d..a730d653018 100644 --- a/pkg/gui/context/local_commits_context.go +++ b/pkg/gui/context/local_commits_context.go @@ -134,7 +134,8 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext { }, } - ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) + ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) + ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect) return ctx } diff --git a/pkg/gui/context/main_context.go b/pkg/gui/context/main_context.go index 66babac0362..716f20bcaad 100644 --- a/pkg/gui/context/main_context.go +++ b/pkg/gui/context/main_context.go @@ -31,7 +31,8 @@ func NewMainContext( SearchTrait: NewSearchTrait(c), } - ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(int) error { return nil })) + ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) + ctx.GetView().SetOnSelectItem(func(int) {}) return ctx } diff --git a/pkg/gui/context/patch_explorer_context.go b/pkg/gui/context/patch_explorer_context.go index 79d585a1226..0828001278f 100644 --- a/pkg/gui/context/patch_explorer_context.go +++ b/pkg/gui/context/patch_explorer_context.go @@ -16,6 +16,10 @@ type PatchExplorerContext struct { getIncludedLineIndices func() []int c *ContextCommon mutex deadlock.Mutex + + // true if we're inside the OnSelectItem callback; in that case we don't want to update the + // search result index. + inOnSelectItemCallback bool } var ( @@ -49,14 +53,14 @@ func NewPatchExplorerContext( SearchTrait: NewSearchTrait(c), } - ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper( - func(selectedLineIdx int) error { - ctx.GetMutex().Lock() - defer ctx.GetMutex().Unlock() - ctx.NavigateTo(selectedLineIdx) - return nil - }), - ) + ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) + ctx.GetView().SetOnSelectItem(func(selectedLineIdx int) { + ctx.GetMutex().Lock() + defer ctx.GetMutex().Unlock() + ctx.inOnSelectItemCallback = true + ctx.NavigateTo(selectedLineIdx) + ctx.inOnSelectItemCallback = false + }) ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged) @@ -113,6 +117,10 @@ func (self *PatchExplorerContext) FocusSelection() { // As far as the view is concerned, we are always selecting a range view.SetRangeSelectStart(startIdx) view.SetCursorY(endIdx - newOriginY) + + if !self.inOnSelectItemCallback { + view.SetNearestSearchPosition() + } } func (self *PatchExplorerContext) GetContentToRender() string { diff --git a/pkg/gui/context/search_trait.go b/pkg/gui/context/search_trait.go index 0b01ee8d635..499d855d449 100644 --- a/pkg/gui/context/search_trait.go +++ b/pkg/gui/context/search_trait.go @@ -36,20 +36,6 @@ func (self *SearchTrait) ClearSearchString() { // used for type switch func (self *SearchTrait) IsSearchableContext() {} -func (self *SearchTrait) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error { - return func(selectedLineIdx int, index int, total int) error { - self.RenderSearchStatus(index, total) - - if total != 0 { - if err := innerFunc(selectedLineIdx); err != nil { - return err - } - } - - return nil - } -} - func (self *SearchTrait) RenderSearchStatus(index int, total int) { keybindingConfig := self.c.UserConfig().Keybinding diff --git a/pkg/gui/context/sub_commits_context.go b/pkg/gui/context/sub_commits_context.go index 1e084077bcf..7e9d9ccab07 100644 --- a/pkg/gui/context/sub_commits_context.go +++ b/pkg/gui/context/sub_commits_context.go @@ -134,7 +134,8 @@ func NewSubCommitsContext( }, } - ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) + ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) + ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect) return ctx } diff --git a/pkg/gui/context/working_tree_context.go b/pkg/gui/context/working_tree_context.go index f7df1b84a60..d37306dc8b0 100644 --- a/pkg/gui/context/working_tree_context.go +++ b/pkg/gui/context/working_tree_context.go @@ -56,7 +56,8 @@ func NewWorkingTreeContext(c *ContextCommon) *WorkingTreeContext { }, } - ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(ctx.OnSearchSelect)) + ctx.GetView().SetRenderSearchStatus(ctx.SearchTrait.RenderSearchStatus) + ctx.GetView().SetOnSelectItem(ctx.OnSearchSelect) return ctx } diff --git a/pkg/gui/controllers/helpers/search_helper.go b/pkg/gui/controllers/helpers/search_helper.go index 67af0695bab..f15c6dda74b 100644 --- a/pkg/gui/controllers/helpers/search_helper.go +++ b/pkg/gui/controllers/helpers/search_helper.go @@ -108,20 +108,15 @@ func (self *SearchHelper) Confirm() error { return self.CancelPrompt() } - var err error switch state.SearchType() { case types.SearchTypeFilter: self.ConfirmFilter() case types.SearchTypeSearch: - err = self.ConfirmSearch() + self.ConfirmSearch() case types.SearchTypeNone: self.c.Context().Pop() } - if err != nil { - return err - } - return self.c.ResetKeybindings() } @@ -144,13 +139,13 @@ func (self *SearchHelper) ConfirmFilter() { self.c.Context().Pop() } -func (self *SearchHelper) ConfirmSearch() error { +func (self *SearchHelper) ConfirmSearch() { state := self.searchState() context, ok := state.Context.(types.ISearchableContext) if !ok { self.c.Log.Warnf("Context %s is searchable", state.Context.GetKey()) - return nil + return } searchString := self.promptContent() @@ -161,7 +156,7 @@ func (self *SearchHelper) ConfirmSearch() error { self.c.Context().Pop() - return context.GetView().Search(searchString, modelSearchResults(context)) + context.GetView().Search(searchString, modelSearchResults(context)) } func modelSearchResults(context types.ISearchableContext) []gocui.SearchPosition { diff --git a/pkg/integration/tests/commit/search.go b/pkg/integration/tests/commit/search.go index 24754b517a6..5439a1b326a 100644 --- a/pkg/integration/tests/commit/search.go +++ b/pkg/integration/tests/commit/search.go @@ -11,6 +11,10 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Skip: false, SetupConfig: func(config *config.AppConfig) {}, SetupRepo: func(shell *Shell) { + // Creating a branch avoids that searching for 't' will unexpectedly match the first commit + // (since it finds it in the extra info line, which is "HEAD -> master") + shell.NewBranch("branch") + shell.EmptyCommit("one") shell.EmptyCommit("two") shell.EmptyCommit("three") @@ -103,6 +107,54 @@ var Search = NewIntegrationTest(NewIntegrationTestArgs{ Contains("three"), Contains("two"), Contains("one").IsSelected(), + ). + NavigateToLine(Contains("three")). + Tap(func() { + t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) + }). + Press("N"). + Tap(func() { + t.Views().Search().IsVisible().Content(Contains("matches for 'o' (1 of 3)")) + }). + Lines( + Contains("four").IsSelected(), + Contains("three"), + Contains("two"), + Contains("one"), + ). + Press(keys.Universal.StartSearch). + Tap(func() { + t.ExpectSearch(). + Type("t"). + Confirm() + + t.Views().Search().IsVisible().Content(Contains("matches for 't' (1 of 2)")) + }). + Lines( + Contains("four"), + Contains("three").IsSelected(), + Contains("two"), + Contains("one"), + ). + SelectPreviousItem(). + Tap(func() { + t.Views().Search().IsVisible().Content(Contains("matches for 't' (1 of 2)")) + }). + Lines( + Contains("four").IsSelected(), + Contains("three"), + Contains("two"), + Contains("one"), + ). + Press("n"). + Tap(func() { + t.Views().Search().IsVisible().Content(Contains("matches for 't' (1 of 2)")) + }). + Lines( + Contains("four"), + Contains("three").IsSelected(), + Contains("two"), + Contains("one"), ) }, }) diff --git a/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go index e5b5c046646..ead9aa9842e 100644 --- a/vendor/github.com/jesseduffield/gocui/view.go +++ b/vendor/github.com/jesseduffield/gocui/view.go @@ -217,29 +217,53 @@ type searcher struct { searchPositions []SearchPosition modelSearchResults []SearchPosition currentSearchIndex int - onSelectItem func(int, int, int) error + onSelectItem func(int) + renderSearchStatus func(int, int) } -func (v *View) SetOnSelectItem(onSelectItem func(int, int, int) error) { +func (v *View) SetRenderSearchStatus(renderSearchStatus func(int, int)) { + v.searcher.renderSearchStatus = renderSearchStatus +} + +func (v *View) SetOnSelectItem(onSelectItem func(int)) { v.searcher.onSelectItem = onSelectItem } +func (v *View) renderSearchStatus(index int, itemCount int) { + if v.searcher.renderSearchStatus != nil { + v.searcher.renderSearchStatus(index, itemCount) + } +} + func (v *View) gotoNextMatch() error { if len(v.searcher.searchPositions) == 0 { return nil } + if v.Highlight && v.oy+v.cy < v.searcher.searchPositions[v.searcher.currentSearchIndex].Y { + // If the selection is before the current match, just jump to the current match and return. + // This can only happen if the user has moved the cursor to before the first match. + v.SelectSearchResult(v.searcher.currentSearchIndex) + return nil + } if v.searcher.currentSearchIndex >= len(v.searcher.searchPositions)-1 { v.searcher.currentSearchIndex = 0 } else { v.searcher.currentSearchIndex++ } - return v.SelectSearchResult(v.searcher.currentSearchIndex) + v.SelectSearchResult(v.searcher.currentSearchIndex) + return nil } func (v *View) gotoPreviousMatch() error { if len(v.searcher.searchPositions) == 0 { return nil } + if v.Highlight && v.oy+v.cy > v.searcher.searchPositions[v.searcher.currentSearchIndex].Y { + // If the selection is after the current match, just jump to the current match and return. + // This happens if the user has moved the cursor down from the current match. + v.SelectSearchResult(v.searcher.currentSearchIndex) + return nil + } if v.searcher.currentSearchIndex == 0 { if len(v.searcher.searchPositions) > 0 { v.searcher.currentSearchIndex = len(v.searcher.searchPositions) - 1 @@ -247,13 +271,14 @@ func (v *View) gotoPreviousMatch() error { } else { v.searcher.currentSearchIndex-- } - return v.SelectSearchResult(v.searcher.currentSearchIndex) + v.SelectSearchResult(v.searcher.currentSearchIndex) + return nil } -func (v *View) SelectSearchResult(index int) error { +func (v *View) SelectSearchResult(index int) { itemCount := len(v.searcher.searchPositions) if itemCount == 0 { - return nil + return } if index > itemCount-1 { index = itemCount - 1 @@ -262,10 +287,10 @@ func (v *View) SelectSearchResult(index int) error { y := v.searcher.searchPositions[index].Y v.FocusPoint(v.ox, y, true) + v.renderSearchStatus(index, itemCount) if v.searcher.onSelectItem != nil { - return v.searcher.onSelectItem(y, index, itemCount) + v.searcher.onSelectItem(y) } - return nil } // Returns , @@ -294,26 +319,29 @@ func (v *View) UpdateSearchResults(str string, modelSearchResults []SearchPositi if len(v.searcher.searchPositions) > 0 { // get the first result past the current cursor currentIndex := 0 - 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 + 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 } } -func (v *View) Search(str string, modelSearchResults []SearchPosition) error { +func (v *View) Search(str string, modelSearchResults []SearchPosition) { v.UpdateSearchResults(str, modelSearchResults) if len(v.searcher.searchPositions) > 0 { - return v.SelectSearchResult(v.searcher.currentSearchIndex) + v.SelectSearchResult(v.searcher.currentSearchIndex) + } else { + v.renderSearchStatus(0, 0) } - - return v.searcher.onSelectItem(-1, -1, 0) } func (v *View) ClearSearch() { @@ -324,8 +352,37 @@ func (v *View) IsSearching() bool { return v.searcher.searchString != "" } +func (v *View) nearestSearchPosition() int { + currentLineIndex := v.cy + v.oy + lastSearchPos := 0 + for i, pos := range v.searcher.searchPositions { + if pos.Y == currentLineIndex { + return i + } + if pos.Y > currentLineIndex { + break + } + lastSearchPos = i + } + return lastSearchPos +} + +func (v *View) SetNearestSearchPosition() { + if len(v.searcher.searchPositions) > 0 { + newPos := v.nearestSearchPosition() + if newPos != v.searcher.currentSearchIndex { + v.searcher.currentSearchIndex = newPos + v.renderSearchStatus(newPos, len(v.searcher.searchPositions)) + } + } +} + func (v *View) FocusPoint(cx int, cy int, scrollIntoView bool) { - lineCount := len(v.lines) + v.writeMutex.Lock() + defer v.writeMutex.Unlock() + + v.refreshViewLinesIfNeeded() + lineCount := len(v.viewLines) if cy < 0 || cy > lineCount { return } @@ -1757,6 +1814,52 @@ func (v *View) setContentLineCount(lineCount int) { v.lines = v.lines[:lineCount] } +// If the current search result is no longer visible after a scroll up, select the last search +// result that is visible in the view, if any, or the first one that is below the view if none is +// visible. +func (v *View) selectVisibleSearchResultAfterScrollUp() { + if !v.Highlight && len(v.searcher.searchPositions) != 0 { + windowBottom := v.oy + v.InnerHeight() + if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y >= windowBottom { + newSearchIndex := v.searcher.currentSearchIndex + for newSearchIndex > 0 && + v.searcher.searchPositions[newSearchIndex-1].Y >= v.oy { + newSearchIndex-- + if v.searcher.searchPositions[newSearchIndex].Y < windowBottom { + break + } + } + if v.searcher.currentSearchIndex != newSearchIndex { + v.searcher.currentSearchIndex = newSearchIndex + v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions)) + } + } + } +} + +// If the current search result is no longer visible after a scroll down, select the first search +// result that is visible in the view, if any, or the last one that is above the view if none is +// visible. +func (v *View) selectVisibleSearchResultAfterScrollDown() { + if !v.Highlight && len(v.searcher.searchPositions) != 0 { + if v.searcher.searchPositions[v.searcher.currentSearchIndex].Y < v.oy { + newSearchIndex := v.searcher.currentSearchIndex + windowBottom := v.oy + v.InnerHeight() + for newSearchIndex+1 < len(v.searcher.searchPositions) && + v.searcher.searchPositions[newSearchIndex+1].Y < windowBottom { + newSearchIndex++ + if v.searcher.searchPositions[newSearchIndex].Y >= v.oy { + break + } + } + if v.searcher.currentSearchIndex != newSearchIndex { + v.searcher.currentSearchIndex = newSearchIndex + v.renderSearchStatus(newSearchIndex, len(v.searcher.searchPositions)) + } + } + } +} + func (v *View) ScrollUp(amount int) { if amount > v.oy { amount = v.oy @@ -1767,6 +1870,7 @@ func (v *View) ScrollUp(amount int) { v.cy += amount v.clearHover() + v.selectVisibleSearchResultAfterScrollUp() } } @@ -1778,6 +1882,7 @@ func (v *View) ScrollDown(amount int) { v.cy -= adjustedAmount v.clearHover() + v.selectVisibleSearchResultAfterScrollDown() } } diff --git a/vendor/modules.txt b/vendor/modules.txt index e742ec9dea9..b7d5ba53304 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -211,7 +211,7 @@ github.com/jesseduffield/go-git/v5/utils/merkletrie/internal/frame github.com/jesseduffield/go-git/v5/utils/merkletrie/noder github.com/jesseduffield/go-git/v5/utils/sync github.com/jesseduffield/go-git/v5/utils/trace -# github.com/jesseduffield/gocui v0.3.1-0.20260103133810-b7e030324985 +# github.com/jesseduffield/gocui v0.3.1-0.20260104174656-7b510338b235 ## explicit; go 1.25 github.com/jesseduffield/gocui # github.com/jesseduffield/lazycore v0.0.0-20221012050358-03d2e40243c5