diff --git a/go.mod b/go.mod index 6ce52869a..4b7c77a3c 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 3097536e7..47f96bf99 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 52807faa9..b4a14045c 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 f04c24ffe..720af133e 100644 --- a/pkg/gui/context/list_context_trait.go +++ b/pkg/gui/context/list_context_trait.go @@ -117,10 +117,9 @@ 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.HandleFocus(types.OnFocusOpts{}) - return nil } 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 e873663c3..a730d6530 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 66babac03..716f20bca 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 79d585a12..c747aa88f 100644 --- a/pkg/gui/context/patch_explorer_context.go +++ b/pkg/gui/context/patch_explorer_context.go @@ -49,14 +49,12 @@ 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.NavigateTo(selectedLineIdx) + }) ctx.SetHandleRenderFunc(ctx.OnViewWidthChanged) diff --git a/pkg/gui/context/search_trait.go b/pkg/gui/context/search_trait.go index 0b01ee8d6..499d855d4 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 1e084077b..7e9d9ccab 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 f7df1b84a..d37306dc8 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 67af0695b..f15c6dda7 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/vendor/github.com/jesseduffield/gocui/view.go b/vendor/github.com/jesseduffield/gocui/view.go index e5b5c0466..ead9aa984 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 e742ec9de..b7d5ba533 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