1
0
mirror of https://github.com/jesseduffield/lazygit.git synced 2025-02-09 13:47:11 +02:00

Introduce filtered list view model

We're going to start supporting filtering of list views
This commit is contained in:
Jesse Duffield 2023-05-27 14:14:43 +10:00
parent fd861826bc
commit a9e2c8129f
43 changed files with 798 additions and 232 deletions

View File

@ -135,10 +135,6 @@ func (gui *Gui) getRandomTip() string {
"To escape a mode, for example cherry-picking, patch-building, diffing, or filtering mode, you can just spam the '%s' button. Unless of course you have `quitOnTopLevelReturn` enabled in your config",
formattedKey(config.Universal.Return),
),
fmt.Sprintf(
"To search for a string in your panel, press '%s'",
formattedKey(config.Universal.StartSearch),
),
fmt.Sprintf(
"You can page through the items of a panel using '%s' and '%s'",
formattedKey(config.Universal.PrevPage),

View File

@ -200,9 +200,21 @@ func (self *ContextMgr) RemoveContexts(contextsToRemove []types.Context) error {
func (self *ContextMgr) deactivateContext(c types.Context, opts types.OnFocusLostOpts) error {
view, _ := self.gui.c.GocuiGui().View(c.GetViewName())
if view != nil && view.IsSearching() {
if err := self.gui.onSearchEscape(); err != nil {
return err
if opts.NewContextKey != context.SEARCH_CONTEXT_KEY {
if searchableContext, ok := c.(types.ISearchableContext); ok {
if view != nil && view.IsSearching() {
view.ClearSearch()
searchableContext.ClearSearchString()
self.gui.helpers.Search.Cancel()
}
}
if filterableContext, ok := c.(types.IFilterableContext); ok {
if filterableContext.GetFilter() != "" {
filterableContext.ClearFilter()
self.gui.helpers.Search.Cancel()
}
}
}
@ -234,6 +246,17 @@ func (self *ContextMgr) ActivateContext(c types.Context, opts types.OnFocusOpts)
return err
}
if searchableContext, ok := c.(types.ISearchableContext); ok {
if searchableContext.GetSearchString() != "" {
self.gui.helpers.Search.DisplaySearchPrompt(searchableContext)
}
}
if filterableContext, ok := c.(types.IFilterableContext); ok {
if filterableContext.GetFilter() != "" {
self.gui.helpers.Search.DisplayFilterPrompt(filterableContext)
}
}
desiredTitle := c.Title()
if desiredTitle != "" {
v.Title = desiredTitle
@ -326,6 +349,30 @@ func (self *ContextMgr) IsCurrent(c types.Context) bool {
return self.Current().GetKey() == c.GetKey()
}
func (self *ContextMgr) AllFilterable() []types.IFilterableContext {
var result []types.IFilterableContext
for _, context := range self.allContexts.Flatten() {
if ctx, ok := context.(types.IFilterableContext); ok {
result = append(result, ctx)
}
}
return result
}
func (self *ContextMgr) AllSearchable() []types.ISearchableContext {
var result []types.ISearchableContext
for _, context := range self.allContexts.Flatten() {
if ctx, ok := context.(types.ISearchableContext); ok {
result = append(result, ctx)
}
}
return result
}
// all list contexts
func (self *ContextMgr) AllList() []types.IListContext {
var listContexts []types.IListContext

View File

@ -7,7 +7,7 @@ import (
)
type BranchesContext struct {
*BasicViewModel[*models.Branch]
*FilteredListViewModel[*models.Branch]
*ListContextTrait
}
@ -17,11 +17,16 @@ var (
)
func NewBranchesContext(c *ContextCommon) *BranchesContext {
viewModel := NewBasicViewModel(func() []*models.Branch { return c.Model().Branches })
viewModel := NewFilteredListViewModel(
func() []*models.Branch { return c.Model().Branches },
func(branch *models.Branch) []string {
return []string{branch.Name}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetBranchListDisplayStrings(
c.Model().Branches,
viewModel.GetItems(),
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
c.Modes().Diffing.Ref,
c.Tr,
@ -30,7 +35,7 @@ func NewBranchesContext(c *ContextCommon) *BranchesContext {
}
self := &BranchesContext{
BasicViewModel: viewModel,
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Branches,

View File

@ -0,0 +1,56 @@
package context
import (
"strings"
"github.com/jesseduffield/lazygit/pkg/utils"
)
type FilteredList[T any] struct {
filteredIndices []int // if nil, we are not filtering
getList func() []T
getFilterFields func(T) []string
filter string
}
func (self *FilteredList[T]) GetFilter() string {
return self.filter
}
func (self *FilteredList[T]) SetFilter(filter string) {
self.filter = filter
self.applyFilter()
}
func (self *FilteredList[T]) ClearFilter() {
self.SetFilter("")
}
func (self *FilteredList[T]) GetList() []T {
if self.filteredIndices == nil {
return self.getList()
}
return utils.ValuesAtIndices(self.getList(), self.filteredIndices)
}
func (self *FilteredList[T]) UnfilteredLen() int {
return len(self.getList())
}
func (self *FilteredList[T]) applyFilter() {
if self.filter == "" {
self.filteredIndices = nil
} else {
self.filteredIndices = []int{}
for i, item := range self.getList() {
for _, field := range self.getFilterFields(item) {
if strings.Contains(field, self.filter) {
self.filteredIndices = append(self.filteredIndices, i)
break
}
}
}
}
}

View File

@ -0,0 +1,26 @@
package context
type FilteredListViewModel[T any] struct {
*FilteredList[T]
*ListViewModel[T]
}
func NewFilteredListViewModel[T any](getList func() []T, getFilterFields func(T) []string) *FilteredListViewModel[T] {
filteredList := &FilteredList[T]{
getList: getList,
getFilterFields: getFilterFields,
}
self := &FilteredListViewModel[T]{
FilteredList: filteredList,
}
listViewModel := NewListViewModel(filteredList.GetList)
self.ListViewModel = listViewModel
return self
}
// used for type switch
func (self *FilteredListViewModel[T]) IsFilterableContext() {}

View File

@ -2,13 +2,13 @@ package context
import "github.com/jesseduffield/lazygit/pkg/gui/context/traits"
type BasicViewModel[T any] struct {
type ListViewModel[T any] struct {
*traits.ListCursor
getModel func() []T
}
func NewBasicViewModel[T any](getModel func() []T) *BasicViewModel[T] {
self := &BasicViewModel[T]{
func NewListViewModel[T any](getModel func() []T) *ListViewModel[T] {
self := &ListViewModel[T]{
getModel: getModel,
}
@ -17,11 +17,11 @@ func NewBasicViewModel[T any](getModel func() []T) *BasicViewModel[T] {
return self
}
func (self *BasicViewModel[T]) Len() int {
func (self *ListViewModel[T]) Len() int {
return len(self.getModel())
}
func (self *BasicViewModel[T]) GetSelected() T {
func (self *ListViewModel[T]) GetSelected() T {
if self.Len() == 0 {
return Zero[T]()
}
@ -29,6 +29,10 @@ func (self *BasicViewModel[T]) GetSelected() T {
return self.getModel()[self.GetSelectedLineIdx()]
}
func (self *ListViewModel[T]) GetItems() []T {
return self.getModel()
}
func Zero[T any]() T {
return *new(T)
}

View File

@ -13,6 +13,7 @@ import (
type LocalCommitsContext struct {
*LocalCommitsViewModel
*ListContextTrait
*SearchTrait
}
var (
@ -57,8 +58,9 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
)
}
return &LocalCommitsContext{
ctx := &LocalCommitsContext{
LocalCommitsViewModel: viewModel,
SearchTrait: NewSearchTrait(c),
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Commits,
@ -73,6 +75,13 @@ func NewLocalCommitsContext(c *ContextCommon) *LocalCommitsContext {
refreshViewportOnChange: true,
},
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error {
ctx.GetList().SetSelectedLineIdx(selectedLineIdx)
return ctx.HandleFocus(types.OnFocusOpts{})
}))
return ctx
}
func (self *LocalCommitsContext) GetSelectedItemId() string {
@ -85,7 +94,7 @@ func (self *LocalCommitsContext) GetSelectedItemId() string {
}
type LocalCommitsViewModel struct {
*BasicViewModel[*models.Commit]
*ListViewModel[*models.Commit]
// If this is true we limit the amount of commits we load, for the sake of keeping things fast.
// If the user attempts to scroll past the end of the list, we will load more commits.
@ -97,7 +106,7 @@ type LocalCommitsViewModel struct {
func NewLocalCommitsViewModel(getModel func() []*models.Commit, c *ContextCommon) *LocalCommitsViewModel {
self := &LocalCommitsViewModel{
BasicViewModel: NewBasicViewModel(getModel),
ListViewModel: NewListViewModel(getModel),
limitCommits: true,
showWholeGitGraph: c.UserConfig.Git.Log.ShowWholeGraph,
}

View File

@ -56,7 +56,7 @@ func (self *MenuContext) GetSelectedItemId() string {
type MenuViewModel struct {
c *ContextCommon
menuItems []*types.MenuItem
*BasicViewModel[*types.MenuItem]
*FilteredListViewModel[*types.MenuItem]
}
func NewMenuViewModel(c *ContextCommon) *MenuViewModel {
@ -65,7 +65,10 @@ func NewMenuViewModel(c *ContextCommon) *MenuViewModel {
c: c,
}
self.BasicViewModel = NewBasicViewModel(func() []*types.MenuItem { return self.menuItems })
self.FilteredListViewModel = NewFilteredListViewModel(
func() []*types.MenuItem { return self.menuItems },
func(item *types.MenuItem) []string { return item.LabelColumns },
)
return self
}
@ -76,11 +79,12 @@ func (self *MenuViewModel) SetMenuItems(items []*types.MenuItem) {
// TODO: move into presentation package
func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]string {
showKeys := slices.Some(self.menuItems, func(item *types.MenuItem) bool {
menuItems := self.FilteredListViewModel.GetItems()
showKeys := slices.Some(menuItems, func(item *types.MenuItem) bool {
return item.Key != nil
})
return slices.Map(self.menuItems, func(item *types.MenuItem) []string {
return slices.Map(menuItems, func(item *types.MenuItem) []string {
displayStrings := item.LabelColumns
if !showKeys {
@ -93,6 +97,7 @@ func (self *MenuViewModel) GetDisplayStrings(_startIdx int, _length int) [][]str
self.c.UserConfig.Keybinding.Universal.Confirm,
self.c.UserConfig.Keybinding.Universal.Select,
self.c.UserConfig.Keybinding.Universal.Return,
self.c.UserConfig.Keybinding.Universal.StartSearch,
}
keyLabel := keybindings.LabelFromKey(item.Key)
keyStyle := style.FgCyan

View File

@ -9,6 +9,7 @@ import (
type PatchExplorerContext struct {
*SimpleContext
*SearchTrait
state *patch_exploring.State
viewTrait *ViewTrait
@ -28,7 +29,7 @@ func NewPatchExplorerContext(
c *ContextCommon,
) *PatchExplorerContext {
return &PatchExplorerContext{
ctx := &PatchExplorerContext{
state: nil,
viewTrait: NewViewTrait(view),
c: c,
@ -42,7 +43,18 @@ func NewPatchExplorerContext(
Focusable: true,
HighlightOnFocus: true,
})),
SearchTrait: NewSearchTrait(c),
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(
func(selectedLineIdx int) error {
ctx.GetMutex().Lock()
defer ctx.GetMutex().Unlock()
return ctx.NavigateTo(ctx.c.IsCurrentContext(ctx), selectedLineIdx)
}),
)
return ctx
}
func (self *PatchExplorerContext) IsPatchExplorerContext() {}

View File

@ -9,7 +9,7 @@ import (
)
type ReflogCommitsContext struct {
*BasicViewModel[*models.Commit]
*FilteredListViewModel[*models.Commit]
*ListContextTrait
}
@ -19,11 +19,16 @@ var (
)
func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
viewModel := NewBasicViewModel(func() []*models.Commit { return c.Model().FilteredReflogCommits })
viewModel := NewFilteredListViewModel(
func() []*models.Commit { return c.Model().FilteredReflogCommits },
func(commit *models.Commit) []string {
return []string{commit.ShortSha(), commit.Name}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetReflogCommitListDisplayStrings(
c.Model().FilteredReflogCommits,
viewModel.GetItems(),
c.State().GetRepoState().GetScreenMode() != types.SCREEN_NORMAL,
c.Modes().CherryPicking.SelectedShaSet(),
c.Modes().Diffing.Ref,
@ -35,7 +40,7 @@ func NewReflogCommitsContext(c *ContextCommon) *ReflogCommitsContext {
}
return &ReflogCommitsContext{
BasicViewModel: viewModel,
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().ReflogCommits,

View File

@ -7,7 +7,7 @@ import (
)
type RemoteBranchesContext struct {
*BasicViewModel[*models.RemoteBranch]
*FilteredListViewModel[*models.RemoteBranch]
*ListContextTrait
*DynamicTitleBuilder
}
@ -20,15 +20,20 @@ var (
func NewRemoteBranchesContext(
c *ContextCommon,
) *RemoteBranchesContext {
viewModel := NewBasicViewModel(func() []*models.RemoteBranch { return c.Model().RemoteBranches })
viewModel := NewFilteredListViewModel(
func() []*models.RemoteBranch { return c.Model().RemoteBranches },
func(remoteBranch *models.RemoteBranch) []string {
return []string{remoteBranch.Name}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetRemoteBranchListDisplayStrings(c.Model().RemoteBranches, c.Modes().Diffing.Ref)
return presentation.GetRemoteBranchListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
}
return &RemoteBranchesContext{
BasicViewModel: viewModel,
DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.RemoteBranchesDynamicTitle),
FilteredListViewModel: viewModel,
DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.RemoteBranchesDynamicTitle),
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().RemoteBranches,

View File

@ -7,7 +7,7 @@ import (
)
type RemotesContext struct {
*BasicViewModel[*models.Remote]
*FilteredListViewModel[*models.Remote]
*ListContextTrait
}
@ -17,14 +17,19 @@ var (
)
func NewRemotesContext(c *ContextCommon) *RemotesContext {
viewModel := NewBasicViewModel(func() []*models.Remote { return c.Model().Remotes })
viewModel := NewFilteredListViewModel(
func() []*models.Remote { return c.Model().Remotes },
func(remote *models.Remote) []string {
return []string{remote.Name}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetRemoteListDisplayStrings(c.Model().Remotes, c.Modes().Diffing.Ref)
return presentation.GetRemoteListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
}
return &RemotesContext{
BasicViewModel: viewModel,
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Remotes,

View File

@ -0,0 +1,70 @@
package context
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/theme"
)
type SearchTrait struct {
c *ContextCommon
searchString string
}
func NewSearchTrait(c *ContextCommon) *SearchTrait {
return &SearchTrait{c: c}
}
func (self *SearchTrait) GetSearchString() string {
return self.searchString
}
func (self *SearchTrait) SetSearchString(searchString string) {
self.searchString = searchString
}
func (self *SearchTrait) ClearSearchString() {
self.SetSearchString("")
}
// used for type switch
func (self *SearchTrait) IsSearchableContext() {}
func (self *SearchTrait) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error {
keybindingConfig := self.c.UserConfig.Keybinding
return func(y int, index int, total int) error {
if total == 0 {
self.c.SetViewContent(
self.c.Views().Search,
fmt.Sprintf(
self.c.Tr.NoMatchesFor,
self.searchString,
theme.OptionsFgColor.Sprintf(self.c.Tr.ExitSearchMode, keybindings.Label(keybindingConfig.Universal.Return)),
),
)
return nil
}
self.c.SetViewContent(
self.c.Views().Search,
fmt.Sprintf(
self.c.Tr.MatchesFor,
self.searchString,
index+1,
total,
theme.OptionsFgColor.Sprintf(
self.c.Tr.SearchKeybindings,
keybindings.Label(keybindingConfig.Universal.NextMatch),
keybindings.Label(keybindingConfig.Universal.PrevMatch),
keybindings.Label(keybindingConfig.Universal.Return),
),
),
)
if err := innerFunc(y); err != nil {
return err
}
return nil
}
}

View File

@ -7,7 +7,7 @@ import (
)
type StashContext struct {
*BasicViewModel[*models.StashEntry]
*FilteredListViewModel[*models.StashEntry]
*ListContextTrait
}
@ -19,14 +19,19 @@ var (
func NewStashContext(
c *ContextCommon,
) *StashContext {
viewModel := NewBasicViewModel(func() []*models.StashEntry { return c.Model().StashEntries })
viewModel := NewFilteredListViewModel(
func() []*models.StashEntry { return c.Model().StashEntries },
func(stashEntry *models.StashEntry) []string {
return []string{stashEntry.Name}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetStashEntryListDisplayStrings(c.Model().StashEntries, c.Modes().Diffing.Ref)
return presentation.GetStashEntryListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
}
return &StashContext{
BasicViewModel: viewModel,
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Stash,

View File

@ -12,9 +12,12 @@ import (
)
type SubCommitsContext struct {
c *ContextCommon
*SubCommitsViewModel
*ListContextTrait
*DynamicTitleBuilder
*SearchTrait
}
var (
@ -26,7 +29,7 @@ func NewSubCommitsContext(
c *ContextCommon,
) *SubCommitsContext {
viewModel := &SubCommitsViewModel{
BasicViewModel: NewBasicViewModel(
ListViewModel: NewListViewModel(
func() []*models.Commit { return c.Model().SubCommits },
),
ref: nil,
@ -60,8 +63,10 @@ func NewSubCommitsContext(
)
}
return &SubCommitsContext{
ctx := &SubCommitsContext{
c: c,
SubCommitsViewModel: viewModel,
SearchTrait: NewSearchTrait(c),
DynamicTitleBuilder: NewDynamicTitleBuilder(c.Tr.SubCommitsDynamicTitle),
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
@ -78,12 +83,19 @@ func NewSubCommitsContext(
refreshViewportOnChange: true,
},
}
ctx.GetView().SetOnSelectItem(ctx.SearchTrait.onSelectItemWrapper(func(selectedLineIdx int) error {
ctx.GetList().SetSelectedLineIdx(selectedLineIdx)
return ctx.HandleFocus(types.OnFocusOpts{})
}))
return ctx
}
type SubCommitsViewModel struct {
// name of the ref that the sub-commits are shown for
ref types.Ref
*BasicViewModel[*models.Commit]
*ListViewModel[*models.Commit]
limitCommits bool
}

View File

@ -7,21 +7,26 @@ import (
)
type SubmodulesContext struct {
*BasicViewModel[*models.SubmoduleConfig]
*FilteredListViewModel[*models.SubmoduleConfig]
*ListContextTrait
}
var _ types.IListContext = (*SubmodulesContext)(nil)
func NewSubmodulesContext(c *ContextCommon) *SubmodulesContext {
viewModel := NewBasicViewModel(func() []*models.SubmoduleConfig { return c.Model().Submodules })
viewModel := NewFilteredListViewModel(
func() []*models.SubmoduleConfig { return c.Model().Submodules },
func(submodule *models.SubmoduleConfig) []string {
return []string{submodule.Name}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetSubmoduleListDisplayStrings(c.Model().Submodules)
return presentation.GetSubmoduleListDisplayStrings(viewModel.GetItems())
}
return &SubmodulesContext{
BasicViewModel: viewModel,
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Submodules,

View File

@ -7,7 +7,7 @@ import (
)
type SuggestionsContext struct {
*BasicViewModel[*types.Suggestion]
*ListViewModel[*types.Suggestion]
*ListContextTrait
State *SuggestionsContextState
@ -40,11 +40,11 @@ func NewSuggestionsContext(
return presentation.GetSuggestionListDisplayStrings(state.Suggestions)
}
viewModel := NewBasicViewModel(getModel)
viewModel := NewListViewModel(getModel)
return &SuggestionsContext{
State: state,
BasicViewModel: viewModel,
State: state,
ListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Suggestions,

View File

@ -7,7 +7,7 @@ import (
)
type TagsContext struct {
*BasicViewModel[*models.Tag]
*FilteredListViewModel[*models.Tag]
*ListContextTrait
}
@ -19,14 +19,19 @@ var (
func NewTagsContext(
c *ContextCommon,
) *TagsContext {
viewModel := NewBasicViewModel(func() []*models.Tag { return c.Model().Tags })
viewModel := NewFilteredListViewModel(
func() []*models.Tag { return c.Model().Tags },
func(tag *models.Tag) []string {
return []string{tag.Name, tag.Message}
},
)
getDisplayStrings := func(startIdx int, length int) [][]string {
return presentation.GetTagListDisplayStrings(c.Model().Tags, c.Modes().Diffing.Ref)
return presentation.GetTagListDisplayStrings(viewModel.GetItems(), c.Modes().Diffing.Ref)
}
return &TagsContext{
BasicViewModel: viewModel,
FilteredListViewModel: viewModel,
ListContextTrait: &ListContextTrait{
Context: NewSimpleContext(NewBaseContext(NewBaseContextOpts{
View: c.Views().Tags,

View File

@ -99,6 +99,7 @@ func (gui *Gui) resetHelpersAndControllers() {
modeHelper,
appStatusHelper,
),
Search: helpers.NewSearchHelper(helperCommon),
}
gui.CustomCommandsClient = custom_commands.NewClient(
@ -162,6 +163,16 @@ func (gui *Gui) resetHelpersAndControllers() {
sideWindowControllerFactory := controllers.NewSideWindowControllerFactory(common)
filterControllerFactory := controllers.NewFilterControllerFactory(common)
for _, context := range gui.c.Context().AllFilterable() {
controllers.AttachControllers(context, filterControllerFactory.Create(context))
}
searchControllerFactory := controllers.NewSearchControllerFactory(common)
for _, context := range gui.c.Context().AllSearchable() {
controllers.AttachControllers(context, searchControllerFactory.Create(context))
}
// allow for navigating between side window contexts
for _, context := range []types.Context{
gui.State.Contexts.Status,
@ -323,6 +334,10 @@ func (gui *Gui) resetHelpersAndControllers() {
suggestionsController,
)
controllers.AttachControllers(gui.State.Contexts.Search,
controllers.NewSearchPromptController(common),
)
controllers.AttachControllers(gui.State.Contexts.Global,
syncController,
undoController,

View File

@ -0,0 +1,48 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type FilterControllerFactory struct {
c *ControllerCommon
}
func NewFilterControllerFactory(c *ControllerCommon) *FilterControllerFactory {
return &FilterControllerFactory{
c: c,
}
}
func (self *FilterControllerFactory) Create(context types.IFilterableContext) *FilterController {
return &FilterController{
baseController: baseController{},
c: self.c,
context: context,
}
}
type FilterController struct {
baseController
c *ControllerCommon
context types.IFilterableContext
}
func (self *FilterController) Context() types.Context {
return self.context
}
func (self *FilterController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.StartSearch),
Handler: self.OpenFilterPrompt,
Description: self.c.Tr.StartFilter,
},
}
}
func (self *FilterController) OpenFilterPrompt() error {
return self.c.Helpers().Search.OpenFilterPrompt(self.context)
}

View File

@ -292,7 +292,9 @@ func (self *ConfirmationHelper) ResizePopupPanel(v *gocui.View, content string)
}
func (self *ConfirmationHelper) resizeMenu() {
itemCount := self.c.Contexts().Menu.GetList().Len()
// we want the unfiltered length here so that if we're filtering we don't
// resize the window
itemCount := self.c.Contexts().Menu.UnfilteredLen()
offset := 3
panelWidth := self.getPopupPanelWidth()
x0, y0, x1, y1 := self.getPopupPanelDimensionsForContentHeight(panelWidth, itemCount+offset)

View File

@ -46,6 +46,7 @@ type Helpers struct {
Mode *ModeHelper
AppStatus *AppStatusHelper
WindowArrangement *WindowArrangementHelper
Search *SearchHelper
}
func NewStubHelpers() *Helpers {
@ -78,5 +79,6 @@ func NewStubHelpers() *Helpers {
Mode: &ModeHelper{},
AppStatus: &AppStatusHelper{},
WindowArrangement: &WindowArrangementHelper{},
Search: &SearchHelper{},
}
}

View File

@ -0,0 +1,196 @@
package helpers
import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
// NOTE: this helper supports both filtering and searching. Filtering is when
// the contents of the list are filtered, whereas searching does not actually
// change the contents of the list but instead just highlights the search.
// The general term we use to capture both searching and filtering is...
// 'searching', which is unfortunate but I can't think of a better name.
type SearchHelper struct {
c *HelperCommon
}
func NewSearchHelper(
c *HelperCommon,
) *SearchHelper {
return &SearchHelper{
c: c,
}
}
func (self *SearchHelper) OpenFilterPrompt(context types.IFilterableContext) error {
state := self.searchState()
state.Context = context
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
promptView := self.promptView()
promptView.ClearTextArea()
promptView.TextArea.TypeString(context.GetFilter())
promptView.RenderTextArea()
if err := self.c.PushContext(self.c.Contexts().Search); err != nil {
return err
}
return nil
}
func (self *SearchHelper) OpenSearchPrompt(context types.Context) error {
state := self.searchState()
state.Context = context
self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix)
promptView := self.promptView()
// TODO: should we show the currently searched thing here? Perhaps we can store that on the context
promptView.ClearTextArea()
promptView.RenderTextArea()
if err := self.c.PushContext(self.c.Contexts().Search); err != nil {
return err
}
return nil
}
func (self *SearchHelper) DisplayFilterPrompt(context types.IFilterableContext) {
state := self.searchState()
state.Context = context
searchString := context.GetFilter()
self.searchPrefixView().SetContent(self.c.Tr.FilterPrefix)
promptView := self.promptView()
promptView.ClearTextArea()
promptView.TextArea.TypeString(searchString)
promptView.RenderTextArea()
}
func (self *SearchHelper) DisplaySearchPrompt(context types.ISearchableContext) {
state := self.searchState()
state.Context = context
searchString := context.GetSearchString()
self.searchPrefixView().SetContent(self.c.Tr.SearchPrefix)
promptView := self.promptView()
promptView.ClearTextArea()
promptView.TextArea.TypeString(searchString)
promptView.RenderTextArea()
}
func (self *SearchHelper) searchState() *types.SearchState {
return self.c.State().GetRepoState().GetSearchState()
}
func (self *SearchHelper) searchPrefixView() *gocui.View {
return self.c.Views().SearchPrefix
}
func (self *SearchHelper) promptView() *gocui.View {
return self.c.Contexts().Search.GetView()
}
func (self *SearchHelper) promptContent() string {
return self.c.Contexts().Search.GetView().TextArea.GetContent()
}
func (self *SearchHelper) Confirm() error {
state := self.searchState()
if self.promptContent() == "" {
return self.CancelPrompt()
}
switch state.SearchType() {
case types.SearchTypeFilter:
return self.ConfirmFilter()
case types.SearchTypeSearch:
return self.ConfirmSearch()
case types.SearchTypeNone:
return self.c.PopContext()
}
return nil
}
func (self *SearchHelper) ConfirmFilter() error {
// We also do this on each keypress but we do it here again just in case
state := self.searchState()
context, ok := state.Context.(types.IFilterableContext)
if !ok {
self.c.Log.Warnf("Context %s is not filterable", state.Context.GetKey())
return nil
}
context.SetFilter(self.promptContent())
_ = self.c.PostRefreshUpdate(state.Context)
return self.c.PopContext()
}
func (self *SearchHelper) ConfirmSearch() error {
state := self.searchState()
if err := self.c.PopContext(); err != nil {
return err
}
context, ok := state.Context.(types.ISearchableContext)
if !ok {
self.c.Log.Warnf("Context %s is searchable", state.Context.GetKey())
return nil
}
searchString := self.promptContent()
context.SetSearchString(searchString)
view := context.GetView()
if err := view.Search(searchString); err != nil {
return err
}
return nil
}
func (self *SearchHelper) CancelPrompt() error {
self.Cancel()
return self.c.PopContext()
}
func (self *SearchHelper) Cancel() {
state := self.searchState()
switch context := state.Context.(type) {
case types.IFilterableContext:
context.SetFilter("")
_ = self.c.PostRefreshUpdate(context)
case types.ISearchableContext:
context.GetView().ClearSearch()
default:
// do nothing
}
state.Context = nil
}
func (self *SearchHelper) OnPromptContentChanged(searchString string) {
state := self.searchState()
switch context := state.Context.(type) {
case types.IFilterableContext:
context.SetFilter(searchString)
_ = self.c.PostRefreshUpdate(context)
case types.ISearchableContext:
// do nothing
default:
// do nothing (shouldn't land here)
}
}

View File

@ -55,7 +55,7 @@ func (self *WindowArrangementHelper) GetWindowDimensions(informationStr string,
self.c.Modes().Filtering.Active()
showInfoSection := self.c.UserConfig.Gui.ShowBottomLine ||
self.c.State().GetRepoState().IsSearching() ||
self.c.State().GetRepoState().InSearchPrompt() ||
self.modeHelper.IsAnyModeActive() ||
self.appStatusHelper.HasStatus()
infoSectionSize := 0
@ -174,11 +174,17 @@ func (self *WindowArrangementHelper) getMidSectionWeights() (int, int) {
}
func (self *WindowArrangementHelper) infoSectionChildren(informationStr string, appStatus string) []*boxlayout.Box {
if self.c.State().GetRepoState().IsSearching() {
if self.c.State().GetRepoState().InSearchPrompt() {
var prefix string
if self.c.State().GetRepoState().GetSearchState().SearchType() == types.SearchTypeSearch {
prefix = self.c.Tr.SearchPrefix
} else {
prefix = self.c.Tr.FilterPrefix
}
return []*boxlayout.Box{
{
Window: "searchPrefix",
Size: runewidth.StringWidth(self.c.Tr.SearchPrefix),
Size: runewidth.StringWidth(prefix),
},
{
Window: "search",

View File

@ -150,18 +150,7 @@ func (self *ListController) GetKeybindings(opts types.KeybindingsOpts) []*types.
{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoTop), Handler: self.HandleGotoTop, Description: self.c.Tr.GotoTop},
{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollLeft), Handler: self.HandleScrollLeft},
{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.ScrollRight), Handler: self.HandleScrollRight},
{
Key: opts.GetKey(opts.Config.Universal.StartSearch),
Handler: func() error { self.c.OpenSearch(); return nil },
Description: self.c.Tr.StartSearch,
Tag: "navigation",
},
{
Key: opts.GetKey(opts.Config.Universal.GotoBottom),
Description: self.c.Tr.GotoBottom,
Handler: self.HandleGotoBottom,
Tag: "navigation",
},
{Tag: "navigation", Key: opts.GetKey(opts.Config.Universal.GotoBottom), Handler: self.HandleGotoBottom, Description: self.c.Tr.GotoBottom},
}
}

View File

@ -693,9 +693,7 @@ func (self *LocalCommitsController) openSearch() error {
}
}
self.c.OpenSearch()
return nil
return self.c.Helpers().Search.OpenSearchPrompt(self.context())
}
func (self *LocalCommitsController) gotoBottom() error {

View File

@ -123,12 +123,6 @@ func (self *PatchExplorerController) GetKeybindings(opts types.KeybindingsOpts)
Key: opts.GetKey(opts.Config.Universal.ScrollRight),
Handler: self.withRenderAndFocus(self.HandleScrollRight),
},
{
Tag: "navigation",
Key: opts.GetKey(opts.Config.Universal.StartSearch),
Handler: func() error { self.c.OpenSearch(); return nil },
Description: self.c.Tr.StartSearch,
},
{
Key: opts.GetKey(opts.Config.Universal.CopyToClipboard),
Handler: self.withLock(self.CopySelectedToClipboard),

View File

@ -0,0 +1,48 @@
package controllers
import (
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type SearchControllerFactory struct {
c *ControllerCommon
}
func NewSearchControllerFactory(c *ControllerCommon) *SearchControllerFactory {
return &SearchControllerFactory{
c: c,
}
}
func (self *SearchControllerFactory) Create(context types.ISearchableContext) *SearchController {
return &SearchController{
baseController: baseController{},
c: self.c,
context: context,
}
}
type SearchController struct {
baseController
c *ControllerCommon
context types.ISearchableContext
}
func (self *SearchController) Context() types.Context {
return self.context
}
func (self *SearchController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.StartSearch),
Handler: self.OpenSearchPrompt,
Description: self.c.Tr.StartSearch,
},
}
}
func (self *SearchController) OpenSearchPrompt() error {
return self.c.Helpers().Search.OpenSearchPrompt(self.context)
}

View File

@ -0,0 +1,53 @@
package controllers
import (
"github.com/jesseduffield/gocui"
"github.com/jesseduffield/lazygit/pkg/gui/types"
)
type SearchPromptController struct {
baseController
c *ControllerCommon
}
var _ types.IController = &SearchPromptController{}
func NewSearchPromptController(
common *ControllerCommon,
) *SearchPromptController {
return &SearchPromptController{
baseController: baseController{},
c: common,
}
}
func (self *SearchPromptController) GetKeybindings(opts types.KeybindingsOpts) []*types.Binding {
return []*types.Binding{
{
Key: opts.GetKey(opts.Config.Universal.Confirm),
Modifier: gocui.ModNone,
Handler: self.confirm,
},
{
Key: opts.GetKey(opts.Config.Universal.Return),
Modifier: gocui.ModNone,
Handler: self.cancel,
},
}
}
func (self *SearchPromptController) Context() types.Context {
return self.context()
}
func (self *SearchPromptController) context() types.Context {
return self.c.Contexts().Search
}
func (self *SearchPromptController) confirm() error {
return self.c.Helpers().Search.Confirm()
}
func (self *SearchPromptController) cancel() error {
return self.c.Helpers().Search.CancelPrompt()
}

View File

@ -89,3 +89,14 @@ func (gui *Gui) promptEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Mo
return matched
}
func (gui *Gui) searchEditor(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) bool {
matched := gui.handleEditorKeypress(v.TextArea, key, ch, mod, false)
v.RenderTextArea()
searchString := v.TextArea.GetContent()
gui.helpers.Search.OnPromptContentChanged(searchString)
return matched
}

View File

@ -26,6 +26,8 @@ type FileTreeViewModel struct {
var _ IFileTreeViewModel = &FileTreeViewModel{}
// how to tackle this? We could just filter down the list of files at a high point and then the rest will take care of itself.
func NewFileTreeViewModel(getFiles func() []*models.File, log *logrus.Entry, showTree bool) *FileTreeViewModel {
fileTree := NewFileTree(getFiles, log, showTree)
listCursor := traits.NewListCursor(fileTree)

View File

@ -201,7 +201,7 @@ type GuiRepoState struct {
SplitMainPanel bool
LimitCommits bool
Searching searchingState
SearchState *types.SearchState
StartupStage types.StartupStage // Allows us to not load everything at once
ContextMgr *ContextMgr
@ -256,8 +256,12 @@ func (self *GuiRepoState) SetScreenMode(value types.WindowMaximisation) {
self.ScreenMode = value
}
func (self *GuiRepoState) IsSearching() bool {
return self.Searching.isSearching
func (self *GuiRepoState) InSearchPrompt() bool {
return self.SearchState.SearchType() != types.SearchTypeNone
}
func (self *GuiRepoState) GetSearchState() *types.SearchState {
return self.SearchState
}
func (self *GuiRepoState) SetSplitMainPanel(value bool) {
@ -268,12 +272,6 @@ func (self *GuiRepoState) GetSplitMainPanel() bool {
return self.SplitMainPanel
}
type searchingState struct {
view *gocui.View
isSearching bool
searchString string
}
func (gui *Gui) onNewRepo(startArgs appTypes.StartArgs, reuseState bool) error {
var err error
gui.git, err = commands.NewGitCommand(
@ -358,6 +356,7 @@ func (gui *Gui) resetState(startArgs appTypes.StartArgs, reuseState bool) types.
ContextMgr: NewContextMgr(gui, contextTree),
Contexts: contextTree,
WindowViewNameMap: initialWindowViewNameMap(contextTree),
SearchState: types.NewSearchState(),
}
gui.RepoStateMap[Repo(currentDir)] = gui.State
@ -584,11 +583,12 @@ func (gui *Gui) Run(startArgs appTypes.StartArgs) error {
})
deadlock.Opts.Disable = !gui.Debug
gui.g.OnSearchEscape = gui.onSearchEscape
if err := gui.Config.ReloadUserConfig(); err != nil {
return nil
}
userConfig := gui.UserConfig
gui.g.OnSearchEscape = func() error { gui.helpers.Search.Cancel(); return nil }
gui.g.SearchEscapeKey = keybindings.GetKey(userConfig.Keybinding.Universal.Return)
gui.g.NextSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.NextMatch)
gui.g.PrevSearchMatchKey = keybindings.GetKey(userConfig.Keybinding.Universal.PrevMatch)

View File

@ -128,10 +128,6 @@ func (self *guiCommon) Mutexes() types.Mutexes {
return self.gui.Mutexes
}
func (self *guiCommon) OpenSearch() {
_ = self.gui.handleOpenSearch(self.gui.currentViewName())
}
func (self *guiCommon) GocuiGui() *gocui.Gui {
return self.gui.g
}

View File

@ -215,18 +215,6 @@ func (self *Gui) GetInitialKeybindings() ([]*types.Binding, []*gocui.ViewMouseBi
Modifier: gocui.ModNone,
Handler: self.scrollUpSecondary,
},
{
ViewName: "search",
Key: opts.GetKey(opts.Config.Universal.Confirm),
Modifier: gocui.ModNone,
Handler: self.handleSearch,
},
{
ViewName: "search",
Key: opts.GetKey(opts.Config.Universal.Return),
Modifier: gocui.ModNone,
Handler: self.handleSearchEscape,
},
{
ViewName: "confirmation",
Key: opts.GetKey(opts.Config.Universal.PrevItem),

View File

@ -132,20 +132,6 @@ func (gui *Gui) layout(g *gocui.Gui) error {
}
view.SelBgColor = theme.GocuiSelectedLineBgColor
// I doubt this is expensive though it's admittedly redundant after the first render
view.SetOnSelectItem(gui.onSelectItemWrapper(listContext.OnSearchSelect))
}
for _, context := range gui.c.Context().AllPatchExplorer() {
context := context
context.GetView().SetOnSelectItem(gui.onSelectItemWrapper(
func(selectedLineIdx int) error {
context.GetMutex().Lock()
defer context.GetMutex().Unlock()
return context.NavigateTo(gui.c.IsCurrentContext(context), selectedLineIdx)
}),
)
}
mainViewWidth, mainViewHeight := gui.Views.Main.Size()

View File

@ -46,9 +46,6 @@ func (gui *Gui) createMenu(opts types.CreateMenuOptions) error {
gui.Views.Menu.Title = opts.Title
gui.Views.Menu.FgColor = theme.GocuiDefaultTextColor
gui.Views.Menu.SetOnSelectItem(gui.onSelectItemWrapper(func(selectedLine int) error {
return nil
}))
gui.Views.Tooltip.Wrap = true
gui.Views.Tooltip.FgColor = theme.GocuiDefaultTextColor

View File

@ -1,103 +0,0 @@
package gui
import (
"fmt"
"github.com/jesseduffield/lazygit/pkg/gui/keybindings"
"github.com/jesseduffield/lazygit/pkg/theme"
)
func (gui *Gui) handleOpenSearch(viewName string) error {
view, err := gui.g.View(viewName)
if err != nil {
return nil
}
gui.State.Searching.isSearching = true
gui.State.Searching.view = view
gui.Views.Search.ClearTextArea()
if err := gui.c.PushContext(gui.State.Contexts.Search); err != nil {
return err
}
return nil
}
func (gui *Gui) handleSearch() error {
gui.State.Searching.searchString = gui.Views.Search.TextArea.GetContent()
if err := gui.c.PopContext(); err != nil {
return err
}
view := gui.State.Searching.view
if view == nil {
return nil
}
if err := view.Search(gui.State.Searching.searchString); err != nil {
return err
}
return nil
}
func (gui *Gui) onSelectItemWrapper(innerFunc func(int) error) func(int, int, int) error {
keybindingConfig := gui.c.UserConfig.Keybinding
return func(y int, index int, total int) error {
if total == 0 {
gui.c.SetViewContent(
gui.Views.Search,
fmt.Sprintf(
gui.Tr.NoMatchesFor,
gui.State.Searching.searchString,
theme.OptionsFgColor.Sprintf(gui.Tr.ExitSearchMode, keybindings.Label(keybindingConfig.Universal.Return)),
),
)
return nil
}
gui.c.SetViewContent(
gui.Views.Search,
fmt.Sprintf(
gui.Tr.MatchesFor,
gui.State.Searching.searchString,
index+1,
total,
theme.OptionsFgColor.Sprintf(
gui.Tr.SearchKeybindings,
keybindings.Label(keybindingConfig.Universal.NextMatch),
keybindings.Label(keybindingConfig.Universal.PrevMatch),
keybindings.Label(keybindingConfig.Universal.Return),
),
),
)
if err := innerFunc(y); err != nil {
return err
}
return nil
}
}
func (gui *Gui) onSearchEscape() error {
gui.State.Searching.isSearching = false
if gui.State.Searching.view != nil {
gui.State.Searching.view.ClearSearch()
gui.State.Searching.view = nil
}
return nil
}
func (gui *Gui) handleSearchEscape() error {
if err := gui.onSearchEscape(); err != nil {
return err
}
if err := gui.c.PopContext(); err != nil {
return err
}
return nil
}

View File

@ -68,8 +68,6 @@ type IGuiCommon interface {
Context() IContextMgr
ActivateContext(context Context) error
// enters search mode for the current view
OpenSearch()
GetConfig() config.AppConfigurer
GetAppState() *config.AppState
@ -251,7 +249,8 @@ type IRepoStateAccessor interface {
SetCurrentPopupOpts(*CreatePopupPanelOpts)
GetScreenMode() WindowMaximisation
SetScreenMode(WindowMaximisation)
IsSearching() bool
InSearchPrompt() bool
GetSearchState() *SearchState
SetSplitMainPanel(bool)
GetSplitMainPanel() bool
}

View File

@ -87,6 +87,24 @@ type Context interface {
HandleRenderToMain() error
}
type IFilterableContext interface {
Context
SetFilter(string)
GetFilter() string
ClearFilter()
IsFilterableContext()
}
type ISearchableContext interface {
Context
SetSearchString(string)
GetSearchString() string
ClearSearchString()
IsSearchableContext()
}
type DiffableContext interface {
Context
@ -104,7 +122,6 @@ type IListContext interface {
GetList() IList
OnSearchSelect(selectedLineIdx int) error
FocusLine()
IsListContext() // used for type switch
}
@ -211,5 +228,7 @@ type IContextMgr interface {
IsCurrent(c Context) bool
ForEach(func(Context))
AllList() []IListContext
AllFilterable() []IFilterableContext
AllSearchable() []ISearchableContext
AllPatchExplorer() []IPatchExplorerContext
}

View File

@ -0,0 +1,31 @@
package types
type SearchType int
const (
SearchTypeNone SearchType = iota
// searching is where matches are highlighted but the content is not filtered down
SearchTypeSearch
// filter is where the list is filtered down to only matches
SearchTypeFilter
)
// TODO: could we remove this entirely?
type SearchState struct {
Context Context
}
func NewSearchState() *SearchState {
return &SearchState{}
}
func (self *SearchState) SearchType() SearchType {
switch self.Context.(type) {
case IFilterableContext:
return SearchTypeFilter
case ISearchableContext:
return SearchTypeSearch
default:
return SearchTypeNone
}
}

View File

@ -95,6 +95,8 @@ func (gui *Gui) createAllViews() error {
gui.Views.SearchPrefix.Frame = false
gui.c.SetViewContent(gui.Views.SearchPrefix, gui.Tr.SearchPrefix)
gui.Views.Search.Editor = gocui.EditorFunc(gui.searchEditor)
gui.Views.Stash.Title = gui.c.Tr.StashTitle
gui.Views.Commits.Title = gui.c.Tr.CommitsTitle

View File

@ -371,6 +371,7 @@ type TranslationSet struct {
NextScreenMode string
PrevScreenMode string
StartSearch string
StartFilter string
Panel string
Keybindings string
KeybindingsLegend string
@ -536,6 +537,7 @@ type TranslationSet struct {
MatchesFor string
SearchKeybindings string
SearchPrefix string
FilterPrefix string
ExitSearchMode string
Actions Actions
Bisect Bisect
@ -1061,7 +1063,8 @@ func EnglishTranslationSet() TranslationSet {
ViewResetToUpstreamOptions: "View upstream reset options",
NextScreenMode: "Next screen mode (normal/half/fullscreen)",
PrevScreenMode: "Prev screen mode",
StartSearch: "Start search",
StartSearch: "Search the current view",
StartFilter: "Filter the current view",
Panel: "Panel",
KeybindingsLegend: "Legend: `<c-b>` means ctrl+b, `<a-b>` means alt+b, `B` means shift+b",
RenameBranch: "Rename branch",
@ -1226,6 +1229,7 @@ func EnglishTranslationSet() TranslationSet {
MatchesFor: "matches for '%s' (%d of %d) %s", // lowercase because it's after other text
SearchKeybindings: "%s: Next match, %s: Previous match, %s: Exit search mode",
SearchPrefix: "Search: ",
FilterPrefix: "Filter: ",
Actions: Actions{
// TODO: combine this with the original keybinding descriptions (those are all in lowercase atm)
CheckoutCommit: "Checkout commit",

View File

@ -113,3 +113,14 @@ func MoveElement[T any](slice []T, from int, to int) []T {
return newSlice
}
func ValuesAtIndices[T any](slice []T, indices []int) []T {
result := make([]T, len(indices))
for i, index := range indices {
// gracefully handling the situation where the index is out of bounds
if index < len(slice) {
result[i] = slice[index]
}
}
return result
}